mirror of
https://git.sr.ht/~emerson/reflectionircd
synced 2025-04-13 09:59:52 +00:00
massive rewrite, moved to single-user for ease of development.
This commit is contained in:
parent
06b2d86017
commit
bb2b913b33
9 changed files with 661 additions and 743 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
node_modules/
|
||||
lib/
|
||||
config.json
|
||||
config*.json
|
||||
!config.example.json
|
50
README.md
50
README.md
|
@ -4,33 +4,35 @@ ReflectionIRCd is an IRCd that bridges Matrix, allowing you to use an IRC client
|
|||
|
||||
This is still in beta. There's `console.log`s all over the place, an inadequate amount of testing and error/bounds checking, several bugs, etc.
|
||||
|
||||
That said, it is usable for basic chatting; if you use a good IRC client, you won't have many problems. See the support matrix (pun intended) below for what it can do. The issue tracker is here: https://todo.sr.ht/~emerson/reflectionircd
|
||||
That said, it is usable for basic chatting; if you use a good IRC client, you won't have many problems. See the [support matrix](#feature-support) (pun intended) for what it can do. The issue tracker is here: https://todo.sr.ht/~emerson/reflectionircd
|
||||
|
||||
## Highlights
|
||||
|
||||
- IRC first: The goal is for modern IRC clients to have feature parity with any other Matrix client
|
||||
- Standalone: Uses the Client-Server API, so you can use it with any homeserver
|
||||
- Multi-user: Log in to multiple Matrix users at the same time
|
||||
|
||||
## Running
|
||||
Copy `config.example.json` to `config.json`, and edit the values:
|
||||
* `serverName` is the name of the IRC server
|
||||
* `port` is the listening port of the server
|
||||
* `certFile` and `keyFile` are the SSL cert and key for Reflection to use
|
||||
* `lazyLoadLimit` is the limit for "lazy load" mode. In lazy load mode, only room members with PL > 0 are shown right away, everyone else is joined once they send an event to the room. This prevents clients crashing or lagging on large rooms.
|
||||
* `mxid` is the MXID of your Matrix user
|
||||
* `homeserver` is the URL of your homeserver. You get this value in Element by going to Settings > Help and About > Advanced
|
||||
* `accessToken` is the access token to use. You get this value underneath the homserver URL in Element.
|
||||
* `saslPassword` is the SASL password to use when connecting to Reflection
|
||||
|
||||
If you're running multiple instances, use multiple config files, i.e. `config-matrix.json` or `config-aria.json`.
|
||||
|
||||
Then `npm ci`, `npm run build`, and `node reflection.js config.json` (replace `config.json` if you named your config differently). You'll see a message about starting initial sync, and then "Synced to network!". Once it's synced, you can connect with your client, specifying the SASL password you put in the config (SASL username doesn't matter).
|
||||
|
||||
|
||||
## Known issues
|
||||
|
||||
### Encrypted channels
|
||||
They don't work. You'll join the channel but won't be able to read any messages. You can send messages but they'll show up as "unencrypted" with a scary icon next to them.
|
||||
|
||||
### Homeserver TLS
|
||||
Homeservers must be available over HTTPS with a valid TLS cert. This shouldn't be an issue because you all do this anyways, right?
|
||||
|
||||
### Joining channels
|
||||
You can't right now. However, if you join a channel via Element (other clients are available), it will auto-join your IRC client to it.
|
||||
|
||||
### PMs
|
||||
You can't send a PM to individual users, since a "PM" in Matrix is just another room, and you can't join rooms from IRC yet. Also because they're just another room, they're given a channel prefix (`&`) so that topics/PLs/etc work correctly.
|
||||
|
||||
### Spaces
|
||||
There's no IRC equivalent to Spaces (I'll probably write a spec for it since it's widely used elsewhere). Since they are just regular rooms, they show up in IRC as just regular rooms. You aren't supposed to send messages to them, once I implement mode handling I'll use `+m` to disallow sending.
|
||||
|
||||
### Highlighting users
|
||||
In order to highlight users, you need to prefix their nick with an `@`, so `@emerson: hi`. Reflection automatically expands this to the user's MXID, so even though it's annoying, it's needed to stop inadvertent highlights for now.
|
||||
* Encrypted rooms don't work. In theory you can use [pantalaimon](https://github.com/matrix-org/pantalaimon), however I haven't tried it yet.
|
||||
* You can't `/JOIN` channels. However, if you join a channel via Element (other clients are available), it will auto-join your IRC client to it.
|
||||
* You can't open a PM to individual users, since a "PM" in Matrix is just another room, and you can't join rooms from IRC yet.
|
||||
* There's no IRC equivalent to Spaces. Since they are just regular rooms, they show up in IRC as just regular rooms.
|
||||
* In order to highlight users, you need to prefix their nick with an `@`, so `@emerson: hi`. Reflection automatically expands this to the user's MXID, so even though it's annoying, it's needed to stop inadvertent highlights for now.
|
||||
|
||||
## FAQs
|
||||
|
||||
|
@ -38,8 +40,6 @@ In order to highlight users, you need to prefix their nick with an `@`, so `@eme
|
|||
|
||||
It's for people who want to use their IRC client to chat on Matrix. It's like Bitlbee or Matterbridge, but focused specifically on IRC <-> Matrix, as well as being a reference server for IRCv3 specifications.
|
||||
|
||||
It's not meant for community use; if you are a homeserver admin and want to allow all your users to use IRC, you would be better off with [heisenbridge](https://github.com/hifi/heisenbridge).
|
||||
|
||||
### Why are there different channel prefixes?
|
||||
|
||||
Because matrix room names are often long and have characters that IRC channel names can't use, Reflection attempts to find a suitable IRC equivalent
|
||||
|
@ -60,12 +60,6 @@ The matrix sync is set with a timeout of 15 seconds. The server is supposed to r
|
|||
### Why is there a random JSON object in my console?
|
||||
That means you were sent an event type that I didn't know existed. Please hop into [the chat](#support) and tell me what the `type` value is.
|
||||
|
||||
## Running
|
||||
Copy `config.example.json` to `config.json`, edit the values if needed, then `npm run build` and then `node reflection.js` to start it
|
||||
|
||||
## Authentication
|
||||
Authentication is done via `SASL PLAIN`, the username is your mxid and the password is an access token from another session plus your server domain, separated by a `:` (so `access_token:matrix.org` if your server is matrix.org). Note this is the domain for `m.server`, not necessarily the homeserver domain. You can find this value by going to https://yourhomeserver.tld/.well-known/matrix/server. If your server is using port 443, you don't need to include the port.
|
||||
|
||||
## Support
|
||||
The main chat is `#reflectionircd` on Libera ([webchat](https://web.libera.chat/gamja/?channel=#reflectionircd)), which is available on Matrix at [`#reflectionircd:matrix.org`](https://matrix.to/#/#reflectionircd:matrix.org). Testing channels are `#reflectionircd-test` on Libera, `#reflectionircd-test:matrix.org` (portal to Libera), and `#reflection-matrix-test:matrix.org` (not linked to IRC). Please test in the testing channels and not in the main channel.
|
||||
|
||||
|
|
|
@ -3,5 +3,9 @@
|
|||
"port": 6697,
|
||||
"certFile": "reflection.pem",
|
||||
"keyFile": "reflection.key",
|
||||
"lazyLoadLimit": 500
|
||||
"lazyLoadLimit": 500,
|
||||
"homeserver": "http://localhost:8443",
|
||||
"mxid": "@testing:matrix.org",
|
||||
"accessToken": "asdlfjasdlfkjasdlkfjasdlk",
|
||||
"SASLPassword": "asdlfkjasdlfkjasdf"
|
||||
}
|
|
@ -3,7 +3,7 @@ import { createServer } from 'tls';
|
|||
import { Client } from './lib/Client.js';
|
||||
import { Server } from './lib/Server.js';
|
||||
|
||||
const config = JSON.parse(readFileSync('config.json'));
|
||||
const config = JSON.parse(readFileSync(process.argv[2]));
|
||||
const ircd = new Server(config);
|
||||
|
||||
const listener = createServer({
|
||||
|
@ -12,10 +12,9 @@ const listener = createServer({
|
|||
});
|
||||
|
||||
listener.on('secureConnection', (c) => {
|
||||
console.log('new Client')
|
||||
const newClient = new Client(c, ircd);
|
||||
new Client(c, ircd);
|
||||
})
|
||||
|
||||
listener.listen(config["port"], () => {
|
||||
console.log("listening");
|
||||
console.log(`Listening on port ${config["port"]}`);
|
||||
})
|
|
@ -1,6 +1,5 @@
|
|||
import axios from "axios";
|
||||
import { IRCUser } from "./IRCUser.js";
|
||||
import { MatrixUser } from "./MatrixUser.js";
|
||||
import { Server } from "./Server.js";
|
||||
|
||||
export class Channel {
|
||||
public name: string
|
||||
|
@ -15,11 +14,9 @@ export class Channel {
|
|||
public guestAccess: string
|
||||
public joinRules: string
|
||||
private syncLocks: Set<string>
|
||||
private initialSyncID: NodeJS.Timeout;
|
||||
constructor(public roomId: string, initialMatrixUser: MatrixUser, private ircUser: IRCUser) {
|
||||
constructor(public roomId: string, private server: Server) {
|
||||
this.name = roomId;
|
||||
this.matrixUsers = new Map();
|
||||
this.matrixUsers.set(initialMatrixUser.nick, initialMatrixUser);
|
||||
this.powerLevels = new Map();
|
||||
this.topic = new Map([['text', ''], ['timestamp', '0'], ['setter', 'matrix']]);
|
||||
this.channelModes = new Map([['n', '']]);
|
||||
|
@ -30,30 +27,47 @@ export class Channel {
|
|||
this.syncLocks = new Set();
|
||||
this.syncLocks.add('isDM');
|
||||
this.doInitialSync();
|
||||
this.initialSyncID = setInterval(this.checkChannelSync.bind(this), 2000);
|
||||
}
|
||||
|
||||
isSynced() {
|
||||
return this.syncLocks.size === 0;
|
||||
}
|
||||
|
||||
addSyncLock(name: string) {
|
||||
this.syncLocks.add(name);
|
||||
}
|
||||
|
||||
delSyncLock(name: string) {
|
||||
this.syncLocks.delete(name);
|
||||
if (this.syncLocks.size === 1) {
|
||||
if (this.matrixUsers.size === 2 && this.name === this.roomId) {
|
||||
const otherUser = [...this.matrixUsers.values()].filter(m => m.mxid !== this.server.mxid);
|
||||
const directRoomsForUser = this.server.directRooms.get(otherUser[0].mxid);
|
||||
if (directRoomsForUser && directRoomsForUser.includes(this.roomId))
|
||||
this.name = `&${otherUser[0].mxid.substring(1)}`
|
||||
}
|
||||
this.syncLocks.delete('isDM');
|
||||
this.server.finishChannelSync(this);
|
||||
}
|
||||
}
|
||||
|
||||
doInitialSync() {
|
||||
this.syncLocks.add("m.room.canonical_alias");
|
||||
axios.get(`https://${this.ircUser.homeserver}/_matrix/client/v3/rooms/${this.roomId}/state/m.room.canonical_alias?access_token=${this.ircUser.accessToken}`).then(response => {
|
||||
this.addSyncLock("m.room.canonical_alias");
|
||||
this.server.apiCall.get(`/rooms/${this.roomId}/state/m.room.canonical_alias`).then(response => {
|
||||
const canonical_alias = response.data["alias"];
|
||||
if (canonical_alias) {
|
||||
this.name = canonical_alias;
|
||||
}
|
||||
this.syncLocks.delete("m.room.canonical_alias")
|
||||
this.delSyncLock("m.room.canonical_alias")
|
||||
}).catch(e => {
|
||||
const errcode = e.response?.data?.errcode;
|
||||
if (errcode !== "M_NOT_FOUND")
|
||||
console.log(e);
|
||||
this.syncLocks.delete("m.room.canonical_alias")
|
||||
this.delSyncLock("m.room.canonical_alias")
|
||||
})
|
||||
this.syncLocks.add("m.room.topic");
|
||||
axios.get(`https://${this.ircUser.homeserver}/_matrix/client/v3/rooms/${this.roomId}/state/m.room.topic?access_token=${this.ircUser.accessToken}`).then(response => {
|
||||
this.syncLocks.delete("m.room.topic")
|
||||
this.addSyncLock("m.room.topic");
|
||||
this.server.apiCall.get(`/rooms/${this.roomId}/state/m.room.topic`).then(response => {
|
||||
this.delSyncLock("m.room.topic")
|
||||
const topicText = response.data["topic"];
|
||||
if (!topicText || this.topic.get("text") !== "")
|
||||
return;
|
||||
|
@ -64,35 +78,35 @@ export class Channel {
|
|||
const errcode = e.response?.data?.errcode;
|
||||
if (errcode !== "M_NOT_FOUND")
|
||||
console.log(this.roomId, e);
|
||||
this.syncLocks.delete("m.room.topic")
|
||||
this.delSyncLock("m.room.topic")
|
||||
})
|
||||
this.syncLocks.add("m.room.members");
|
||||
axios.get(`https://${this.ircUser.homeserver}/_matrix/client/v3/rooms/${this.roomId}/joined_members?access_token=${this.ircUser.accessToken}`).then(response => {
|
||||
this.addSyncLock("m.room.members");
|
||||
this.server.apiCall.get(`/rooms/${this.roomId}/joined_members`).then(response => {
|
||||
const allMembers = response.data["joined"];
|
||||
const allMxids = Object.keys(allMembers);
|
||||
if (allMxids.length > this.ircUser.server.config["lazyLoadLimit"]) {
|
||||
if (allMxids.length > this.server.config["lazyLoadLimit"]) {
|
||||
this.channelModes.set('u', '');
|
||||
} else {
|
||||
for (const member of allMxids) {
|
||||
const nextMatrixUser = this.ircUser.getOrCreateMatrixUser(member);
|
||||
const nextMatrixUser = this.server.getOrCreateMatrixUser(member);
|
||||
this.matrixUsers.set(nextMatrixUser.nick, nextMatrixUser);
|
||||
}
|
||||
}
|
||||
this.syncLocks.delete("m.room.members")
|
||||
this.delSyncLock("m.room.members")
|
||||
}).catch(e => {
|
||||
const errcode = e.response?.data?.errcode;
|
||||
if (errcode !== "M_NOT_FOUND")
|
||||
console.log(e);
|
||||
this.syncLocks.delete("m.room.members")
|
||||
this.delSyncLock("m.room.members")
|
||||
})
|
||||
this.syncLocks.add("m.room.power_levels")
|
||||
axios.get(`https://${this.ircUser.homeserver}/_matrix/client/v3/rooms/${this.roomId}/state/m.room.power_levels?access_token=${this.ircUser.accessToken}`).then(response => {
|
||||
this.syncLocks.delete("m.room.power_levels")
|
||||
this.addSyncLock("m.room.power_levels")
|
||||
this.server.apiCall.get(`/rooms/${this.roomId}/state/m.room.power_levels`).then(response => {
|
||||
this.delSyncLock("m.room.power_levels")
|
||||
const users = response.data["users"];
|
||||
if (!users)
|
||||
return;
|
||||
for (const [mxid, pl] of Object.entries(users)) {
|
||||
const nextMatrixUser = this.ircUser.getOrCreateMatrixUser(mxid);
|
||||
const nextMatrixUser = this.server.getOrCreateMatrixUser(mxid);
|
||||
this.matrixUsers.set(nextMatrixUser.nick, nextMatrixUser);
|
||||
const numPl = Number(pl);
|
||||
if (numPl > 0)
|
||||
|
@ -102,23 +116,11 @@ export class Channel {
|
|||
const errcode = e.response?.data?.errcode;
|
||||
if (errcode !== "M_NOT_FOUND")
|
||||
console.log(e);
|
||||
this.syncLocks.delete("m.room.power_levels")
|
||||
this.delSyncLock("m.room.power_levels")
|
||||
})
|
||||
}
|
||||
|
||||
checkChannelSync() {
|
||||
if (this.syncLocks.size === 1) {
|
||||
if (this.matrixUsers.size === 2 && this.name === this.roomId) {
|
||||
const otherUser = [...this.matrixUsers.values()].filter(m => m.nick !== this.ircUser.nick);
|
||||
const directRoomsForUser = this.ircUser.directRooms.get(otherUser[0].mxid);
|
||||
if (directRoomsForUser && directRoomsForUser.includes(this.roomId))
|
||||
this.name = `&${otherUser[0].mxid.substring(1)}`
|
||||
}
|
||||
this.syncLocks.delete('isDM');
|
||||
this.ircUser.finishChannelSync(this);
|
||||
clearInterval(this.initialSyncID);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getNickPowerLevelMapping(nick: string): string {
|
||||
let opStatus = '';
|
||||
|
|
130
src/Client.ts
130
src/Client.ts
|
@ -1,23 +1,20 @@
|
|||
import axios from 'axios';
|
||||
import { Axios } from 'axios';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Socket } from 'net';
|
||||
import { Channel } from './Channel.js';
|
||||
import { IRCUser } from './IRCUser.js';
|
||||
import { MatrixUser } from './MatrixUser.js';
|
||||
import { IRCMessage, parseIRCMessage } from './Message.js';
|
||||
import numerics from './numerics.js';
|
||||
import { Server } from './Server.js';
|
||||
|
||||
export class Client {
|
||||
user: IRCUser|null
|
||||
capVersion: string
|
||||
enabledCaps: Map<string, string>
|
||||
allCaps: Map<string, string>
|
||||
localNick: string
|
||||
localUsername: string
|
||||
localRealname: string
|
||||
deviceId: string
|
||||
user: MatrixUser
|
||||
isRegistered: boolean
|
||||
apiCall: Axios;
|
||||
constructor(private socket: Socket, public server: Server) {
|
||||
this.user = null;
|
||||
this.capVersion = '301';
|
||||
this.enabledCaps = new Map();
|
||||
this.allCaps = new Map([
|
||||
|
@ -31,14 +28,18 @@ export class Client {
|
|||
["sasl", "PLAIN"],
|
||||
["server-time", ""],
|
||||
]);
|
||||
this.localNick = 'none';
|
||||
this.localUsername = 'none';
|
||||
this.localRealname = 'none';
|
||||
this.deviceId = "";
|
||||
this.user = this.server.ourMatrixUser;
|
||||
this.isRegistered = false;
|
||||
this.apiCall = this.server.apiCall;
|
||||
this.server.doLog("New client connected");
|
||||
this.socket.on('data', (data) => this.receiveData(data));
|
||||
//this.socket.on('close', (e) => {if (this.user) this.user.handleClientClose(this, e)});
|
||||
}
|
||||
|
||||
checkIfRegistered() {
|
||||
return this.isRegistered;
|
||||
}
|
||||
|
||||
receiveData(data: Buffer|String) {
|
||||
const dataArray = data.toString().split('\r\n');
|
||||
dataArray.forEach(m => {
|
||||
|
@ -48,16 +49,8 @@ export class Client {
|
|||
});
|
||||
}
|
||||
|
||||
checkIfRegistered() {
|
||||
if (this.user === null)
|
||||
this.sendMessage(this.server.name, "451", [this.localNick, "You have not registered"]);
|
||||
|
||||
return this.user !== null;
|
||||
}
|
||||
|
||||
getMatrixUserFromNick(targetNick: string) {
|
||||
if (!this.user) return false;
|
||||
const target = this.user.nickToMatrixUser.get(targetNick);
|
||||
const target = this.server.nickToMatrixUser.get(targetNick);
|
||||
if (!target) {
|
||||
this.sendMessage(this.server.name, "401", [this.user.nick, targetNick, "No such nick"]);
|
||||
return false;
|
||||
|
@ -66,8 +59,7 @@ export class Client {
|
|||
}
|
||||
|
||||
getChannel(channel: string) {
|
||||
if (!this.user) return false;
|
||||
const targetChannel = this.user.channels.get(channel);
|
||||
const targetChannel = this.server.channels.get(channel);
|
||||
if (!targetChannel) {
|
||||
this.sendMessage(this.server.name, "403", [this.user.nick, channel, "No such channel"]);
|
||||
return false;
|
||||
|
@ -76,8 +68,7 @@ export class Client {
|
|||
}
|
||||
|
||||
checkIfInChannel(channel: Channel) {
|
||||
if (!this.user) return false;
|
||||
if (!this.user.channels.get(channel.name)) {
|
||||
if (!this.server.channels.get(channel.name)) {
|
||||
this.sendMessage(this.server.name, "442", [this.user.nick, "You're not on that channel"]);
|
||||
return false;
|
||||
}
|
||||
|
@ -85,7 +76,6 @@ export class Client {
|
|||
}
|
||||
|
||||
checkMinParams(message: IRCMessage, neededNumber: number) {
|
||||
if (!this.user) return false;
|
||||
if (message.params.length < neededNumber) {
|
||||
this.sendMessage(this.server.name, "461", [this.user.nick, message.command, "Not enough parameters"]);
|
||||
return false;
|
||||
|
@ -164,64 +154,11 @@ export class Client {
|
|||
this.sendMessage(this.server.name, '904', numerics['904']('*'))
|
||||
this.closeConnectionWithError('Invalid authentication')
|
||||
}
|
||||
const mxid = authArray[1];
|
||||
const accessTokenAndServer = authArray[2];
|
||||
const sepIndex = accessTokenAndServer.indexOf(":");
|
||||
const accessToken = accessTokenAndServer.slice(0, sepIndex);
|
||||
const homeserver = accessTokenAndServer.slice(sepIndex+1);
|
||||
console.log(accessToken, homeserver);
|
||||
const thisIRCUser = this.server.getOrCreateIRCUser(mxid, accessToken, homeserver);
|
||||
thisIRCUser.getVerification().then((response) => {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
this.sendMessage(this.server.name, '904', numerics['904']('*'))
|
||||
this.closeConnectionWithError('Invalid authentication')
|
||||
} else if (response.status === 429) {
|
||||
this.sendMessage(this.server.name, '904', numerics['904']('*'))
|
||||
this.closeConnectionWithError('rate limited, please try again later')
|
||||
} else if (response.status !== 200) {
|
||||
this.sendMessage(this.server.name, '904', numerics['904']('*'))
|
||||
this.closeConnectionWithError('verification failed, please check credentials')
|
||||
}
|
||||
this.deviceId = response.data.device_id
|
||||
if (response.data.user_id !== mxid) {
|
||||
this.sendMessage(this.server.name, '904', numerics['904']('*'))
|
||||
this.closeConnectionWithError('access token does not match mxid, please check credentials')
|
||||
} else {
|
||||
this.user = thisIRCUser;
|
||||
this.sendMessage(this.server.name, '900', [this.user.nick, this.user.getMask(), this.user.accountName, `You are now logged in as ${this.user.nick}`]);
|
||||
this.sendMessage(this.server.name, '903', [this.user.nick, "SASL authentication successful"]);
|
||||
if (this.user.isSynced()) {
|
||||
this.user.addClient(this);
|
||||
} else {
|
||||
axios.get(`https://${this.user.homeserver}/_matrix/client/v3/sync?access_token=${accessToken}`).then(response => {
|
||||
const data = response.data;
|
||||
const rooms = data.rooms;
|
||||
if (this.user === null)
|
||||
return;
|
||||
if (rooms['join']) {
|
||||
for (const roomId of Object.keys(rooms.join)) {
|
||||
const targetChannel = this.user.getOrCreateIRCChannel(roomId);
|
||||
//@ts-ignore
|
||||
rooms.join[roomId].state.events.forEach((nextEvent: any) => {
|
||||
if (targetChannel.isSynced() && this.user)
|
||||
this.user.routeMatrixEvent(nextEvent, targetChannel)
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.user === null)
|
||||
return;
|
||||
this.user.nextBatch = data.next_batch;
|
||||
this.user.addClient(this);
|
||||
}).catch(function (error) {
|
||||
console.log(error);
|
||||
})
|
||||
}
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
this.sendMessage(this.server.name, '904', numerics['904']('*'))
|
||||
this.closeConnectionWithError('Error connecting to the Matrix server')
|
||||
})
|
||||
if (authArray[2] === this.server.config.SASLPassword) {
|
||||
this.sendMessage(this.server.name, '900', [this.user.nick, this.server.getMask(), this.user.accountName, `You are now logged in as ${this.user.nick}`]);
|
||||
this.sendMessage(this.server.name, '903', [this.user.nick, "SASL authentication successful"]);
|
||||
this.isRegistered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -251,7 +188,7 @@ export class Client {
|
|||
break;
|
||||
}
|
||||
case 'END': {
|
||||
if (this.user !== null) {
|
||||
if (this.isRegistered) {
|
||||
this.doRegistration(message);
|
||||
}
|
||||
else {
|
||||
|
@ -272,7 +209,7 @@ export class Client {
|
|||
"reason": (message.params.length === 2) ? message.params[1] : ""
|
||||
}
|
||||
const newTxnid = randomUUID();
|
||||
axios.put(`https://${this.user.homeserver}/_matrix/client/v3/rooms/${targetChannel.roomId}/redact/${eventId}/${newTxnid}?access_token=${this.user.accessToken}`, data).catch(function (error) {
|
||||
this.apiCall.put(`/rooms/${targetChannel.roomId}/redact/${eventId}/${newTxnid}`, data).catch(function (error) {
|
||||
if (error.response) {
|
||||
console.log(error.response.data);
|
||||
} else if (error.request) {
|
||||
|
@ -300,7 +237,7 @@ export class Client {
|
|||
"reason": reason,
|
||||
"user_id": targetUser.mxid
|
||||
}
|
||||
axios.post(`https://${this.user.homeserver}/_matrix/client/v3/rooms/${targetChannel.roomId}/invite?access_token=${this.user.accessToken}`, data).catch(function (error) {
|
||||
this.apiCall.post(`/rooms/${targetChannel.roomId}/invite`, data).catch(function (error) {
|
||||
if (error.response) {
|
||||
console.log(error.response.data);
|
||||
} else if (error.request) {
|
||||
|
@ -328,7 +265,7 @@ export class Client {
|
|||
"reason": reason,
|
||||
"user_id": targetUser.mxid
|
||||
}
|
||||
axios.post(`https://${this.user.homeserver}/_matrix/client/v3/rooms/${targetChannel.roomId}/kick?access_token=${this.user.accessToken}`, data).catch(function (error) {
|
||||
this.apiCall.post(`/rooms/${targetChannel.roomId}/kick`, data).catch(function (error) {
|
||||
if (error.response) {
|
||||
console.log(error.response.data);
|
||||
} else if (error.request) {
|
||||
|
@ -343,7 +280,7 @@ export class Client {
|
|||
doMODE(message: IRCMessage) {
|
||||
if (!this.checkIfRegistered() || !this.checkMinParams(message, 1) || !this.user)
|
||||
return;
|
||||
const targetChannel = this.user.channels.get(message.params[0]);
|
||||
const targetChannel = this.server.channels.get(message.params[0]);
|
||||
if (!targetChannel) {
|
||||
if (message.params[0] !== this.user.nick) {
|
||||
this.sendMessage(this.server.name, "502", [this.user.nick, "Can't view mode for other users"]);
|
||||
|
@ -396,8 +333,8 @@ export class Client {
|
|||
}
|
||||
}
|
||||
const newTxnid = randomUUID();
|
||||
this.user.txnIdStore.set(newTxnid, this);
|
||||
axios.put(`https://${this.user.homeserver}/_matrix/client/v3/rooms/${targetChannel.roomId}/send/m.room.message/${newTxnid}?access_token=${this.user.accessToken}`, content).catch(function (error) {
|
||||
this.server.txnIdStore.set(newTxnid, this);
|
||||
this.apiCall.put(`/rooms/${targetChannel.roomId}/send/m.room.message/${newTxnid}`, content).catch(function (error) {
|
||||
if (error.response) {
|
||||
console.log(error.response.data);
|
||||
} else if (error.request) {
|
||||
|
@ -449,7 +386,7 @@ export class Client {
|
|||
if (!this.user || !targetChannel) return;
|
||||
if (!this.checkIfInChannel(targetChannel)) return;
|
||||
const reason = (message.params.length === 2) ? message.params[1] : "";
|
||||
axios.post(`https://${this.user.homeserver}/_matrix/client/v3/rooms/${targetChannel.roomId}/leave?access_token=${this.user.accessToken}`, {"reason": reason}).then(response => {
|
||||
this.apiCall.post(`/rooms/${targetChannel.roomId}/leave`, {"reason": reason}).then(response => {
|
||||
if (response.status === 200) {
|
||||
//@ts-ignore
|
||||
this.user.getClients().forEach(c => {
|
||||
|
@ -492,8 +429,8 @@ export class Client {
|
|||
}
|
||||
}
|
||||
const newTxnid = randomUUID();
|
||||
this.user.txnIdStore.set(newTxnid, this);
|
||||
axios.put(`https://${this.user.homeserver}/_matrix/client/v3/rooms/${targetChannel.roomId}/send/m.reaction/${newTxnid}?access_token=${this.user.accessToken}`, content).catch(function (error) {
|
||||
this.server.txnIdStore.set(newTxnid, this);
|
||||
this.apiCall.put(`/rooms/${targetChannel.roomId}/send/m.reaction/${newTxnid}`, content).catch(function (error) {
|
||||
if (error.response) {
|
||||
console.log(error.response.data);
|
||||
} else if (error.request) {
|
||||
|
@ -530,7 +467,7 @@ export class Client {
|
|||
return;
|
||||
}
|
||||
const topic = message.params[1];
|
||||
axios.put(`https://${this.user.homeserver}/_matrix/client/v3/rooms/${targetChannel.roomId}/state/m.room.topic?access_token=${this.user.accessToken}`, {"topic": topic}).catch(function (error) {
|
||||
this.apiCall.put(`/rooms/${targetChannel.roomId}/state/m.room.topic`, {"topic": topic}).catch(function (error) {
|
||||
if (error.response) {
|
||||
console.log(error.response.data);
|
||||
} else if (error.request) {
|
||||
|
@ -591,8 +528,7 @@ export class Client {
|
|||
this.sendMessage(this.server.name, '376', numerics['376'](this.user.nick));
|
||||
|
||||
this.sendMessage(this.user.nick, 'MODE', [this.user.nick, '+i']);
|
||||
if (!this.user.isSynced())
|
||||
this.sendMessage(this.server.name, 'NOTICE', [this.user.nick, 'Please wait for initial sync, this may take a while if you are in many large channels']);
|
||||
this.server.addClient(this);
|
||||
}
|
||||
|
||||
sendMessage(prefix: string, command: string, params: string[], tags: Map<string, string> = new Map()) {
|
||||
|
|
565
src/IRCUser.ts
565
src/IRCUser.ts
|
@ -1,565 +0,0 @@
|
|||
import axios from "axios";
|
||||
import { Channel } from "./Channel.js";
|
||||
import { Client } from "./Client.js";
|
||||
import { MatrixUser } from "./MatrixUser.js";
|
||||
import { Server } from "./Server.js";
|
||||
|
||||
export class IRCUser {
|
||||
private clients: Set<Client>
|
||||
public channels: Map<string, Channel>
|
||||
public roomIdToChannel: Map<string, Channel>
|
||||
public directRooms: Map<string, string[]>
|
||||
private syncLocks: Set<Channel>
|
||||
public matrixUsers: Map<string, MatrixUser>
|
||||
public nickToMatrixUser: Map<string, MatrixUser>
|
||||
public nick: string
|
||||
private ident: string
|
||||
private hostname: string
|
||||
public accountName: string
|
||||
public realname: string
|
||||
public ourMatrixUser: MatrixUser
|
||||
public txnIdStore: Map<string, Client>
|
||||
public nextBatch: string
|
||||
private initialSync: boolean
|
||||
private isSyncing: boolean
|
||||
private currentSyncTime: number
|
||||
private syncIntervalID: NodeJS.Timeout;
|
||||
constructor(public mxid: string, public accessToken: string, public homeserver: string, public server: Server) {
|
||||
this.clients = new Set();
|
||||
this.channels = new Map();
|
||||
this.roomIdToChannel = new Map();
|
||||
this.directRooms = new Map();
|
||||
this.syncLocks = new Set();
|
||||
this.matrixUsers = new Map();
|
||||
this.nickToMatrixUser = new Map();
|
||||
const mxidSplit = mxid.split(':')
|
||||
this.nick = mxidSplit[0].substring(1);
|
||||
this.ident = this.nick;
|
||||
this.hostname = mxidSplit[1];
|
||||
this.accountName = mxid.slice(1);
|
||||
this.realname = this.accountName;
|
||||
this.ourMatrixUser = new MatrixUser(this.mxid, this.nick);
|
||||
this.matrixUsers.set(this.mxid, this.ourMatrixUser);
|
||||
this.nickToMatrixUser.set(this.nick, this.ourMatrixUser);
|
||||
this.txnIdStore = new Map();
|
||||
this.nextBatch = "";
|
||||
this.initialSync = false;
|
||||
this.isSyncing = false;
|
||||
this.currentSyncTime = 0;
|
||||
this.syncIntervalID = setInterval(this.doSync.bind(this), 2000);
|
||||
axios.get(`https://${this.homeserver}/_matrix/client/v3/user/${this.mxid}/account_data/m.direct?access_token=${this.accessToken}`).then(response => {
|
||||
if (!response.data)
|
||||
return;
|
||||
//@ts-ignore
|
||||
Object.entries(response.data).forEach(m => this.directRooms.set(m[0], m[1]));
|
||||
}).catch(e => {
|
||||
const errcode = e.response?.data?.errcode;
|
||||
if (errcode !== "M_NOT_FOUND")
|
||||
console.log(e);
|
||||
})
|
||||
}
|
||||
|
||||
getOrCreateMatrixUser(mxid: string): MatrixUser {
|
||||
let maybeMatrixUser = this.matrixUsers.get(mxid);
|
||||
if (maybeMatrixUser) {
|
||||
return maybeMatrixUser;
|
||||
}
|
||||
const localPart = mxid.split(":")[0].substring(1)
|
||||
if (!this.nickToMatrixUser.has(localPart)) {
|
||||
const newMatrixUser = new MatrixUser(mxid, localPart);
|
||||
this.matrixUsers.set(mxid, newMatrixUser);
|
||||
this.nickToMatrixUser.set(localPart, newMatrixUser);
|
||||
return newMatrixUser;
|
||||
}
|
||||
const homeserver = mxid.split(":")[1];
|
||||
const homeserverArray = homeserver.split('.');
|
||||
const baseDomainNum = homeserverArray.length - 2;
|
||||
let potentialNick = `${localPart}-${homeserverArray[baseDomainNum]}`;
|
||||
if (!this.nickToMatrixUser.has(potentialNick)) {
|
||||
const newMatrixUser = new MatrixUser(mxid, potentialNick);
|
||||
this.matrixUsers.set(mxid, newMatrixUser);
|
||||
this.nickToMatrixUser.set(potentialNick, newMatrixUser);
|
||||
return newMatrixUser;
|
||||
}
|
||||
potentialNick = `${localPart}-${homeserver}`;
|
||||
const newMatrixUser = new MatrixUser(mxid, potentialNick);
|
||||
this.matrixUsers.set(mxid, newMatrixUser);
|
||||
this.nickToMatrixUser.set(potentialNick, newMatrixUser);
|
||||
return newMatrixUser;
|
||||
}
|
||||
|
||||
getOrCreateIRCChannel(roomId: string): Channel {
|
||||
const maybeChannel = this.roomIdToChannel.get(roomId);
|
||||
if (maybeChannel)
|
||||
return maybeChannel;
|
||||
|
||||
const newChannel = new Channel(roomId, this.ourMatrixUser, this);
|
||||
this.syncLocks.add(newChannel);
|
||||
this.roomIdToChannel.set(roomId, newChannel);
|
||||
this.channels.set(roomId, newChannel);
|
||||
return newChannel;
|
||||
}
|
||||
|
||||
isSynced() {
|
||||
return this.nextBatch !== "";
|
||||
}
|
||||
|
||||
finishChannelSync(targetChannel: Channel) {
|
||||
this.syncLocks.delete(targetChannel);
|
||||
this.channels.set(targetChannel.name, targetChannel);
|
||||
this.roomIdToChannel.set(targetChannel.roomId, targetChannel);
|
||||
this.clients.forEach(c => this.joinNewIRCClient(c, targetChannel));
|
||||
if (this.initialSync === false && this.syncLocks.size === 0) {
|
||||
this.initialSync = true;
|
||||
this.sendToAll(this.server.name, 'NOTICE', [this.nick, 'You are now synced to the network!']);
|
||||
}
|
||||
}
|
||||
|
||||
getVerification() {
|
||||
return axios.get(`https://${this.homeserver}/_matrix/client/v3/account/whoami?access_token=${this.accessToken}`, {
|
||||
validateStatus: function (status) {
|
||||
return status < 500;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getMask(): string {
|
||||
return `${this.nick}!${this.ident}@${this.hostname}`;
|
||||
}
|
||||
|
||||
getClients(): Set<Client> {
|
||||
return this.clients;
|
||||
}
|
||||
|
||||
joinNewIRCClient(client: Client, targetChannel: Channel) {
|
||||
if (client.enabledCaps.has('extended-join')) {
|
||||
client.sendMessage(this.getMask(), "JOIN", [targetChannel.name, this.accountName, this.mxid], new Map([['account', this.realname]]));
|
||||
} else {
|
||||
client.sendMessage(this.getMask(), "JOIN", [targetChannel.name], new Map([['account', this.realname]]));
|
||||
}
|
||||
client.sendNAMES(targetChannel);
|
||||
client.sendTOPIC(targetChannel);
|
||||
}
|
||||
|
||||
addClient(client: Client) {
|
||||
this.clients.add(client);
|
||||
if (this.initialSync) {
|
||||
for (const channel of this.channels.values()) {
|
||||
this.joinNewIRCClient(client, channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doSync(): void {
|
||||
if (!this.isSynced()) {
|
||||
console.log(`[${new Date().toISOString()}] ${this.homeserver}: not syncing, initial sync not completed`);
|
||||
return;
|
||||
}
|
||||
if (this.isSyncing) {
|
||||
if ((Date.now() - this.currentSyncTime) > 20000)
|
||||
console.log(`[${new Date().toISOString()}] ${this.homeserver}: Sync is lagging, current sync has been running for ${Date.now() - this.currentSyncTime} milliseconds`);
|
||||
return;
|
||||
}
|
||||
if (this.currentSyncTime === 0)
|
||||
console.log(`[${new Date().toISOString()}] ${this.homeserver}: Synced to network!`);
|
||||
this.currentSyncTime = Date.now();
|
||||
this.isSyncing = true;
|
||||
const endpoint = `https://${this.homeserver}/_matrix/client/v3/sync?access_token=${this.accessToken}&since=${this.nextBatch}&timeout=15000`;
|
||||
axios.get(endpoint).then(response => {
|
||||
const data = response.data;
|
||||
this.nextBatch = data.next_batch;
|
||||
const rooms = data.rooms;
|
||||
if (rooms && rooms['join']) {
|
||||
for (const roomId of Object.keys(rooms.join)) {
|
||||
const targetChannel = this.getOrCreateIRCChannel(roomId);
|
||||
rooms.join[roomId].timeline.events.forEach((nextEvent: any) => {
|
||||
if (targetChannel.isSynced())
|
||||
this.routeMatrixEvent(nextEvent, targetChannel);
|
||||
});
|
||||
}
|
||||
}
|
||||
this.isSyncing = false;
|
||||
}).catch((error) => {
|
||||
if (error.response) {
|
||||
console.log(`[${new Date().toISOString()}] Error from ${this.homeserver}: ${error.response.status} ${error.response.statusText}`)
|
||||
} else {
|
||||
console.log(`[${new Date().toISOString()}] Error from ${this.homeserver}: ${error}`);
|
||||
}
|
||||
this.isSyncing = false;
|
||||
});
|
||||
}
|
||||
|
||||
routeMatrixEvent(nextEvent: any, targetChannel: Channel) {
|
||||
switch (nextEvent["type"]) {
|
||||
case 'm.reaction':
|
||||
this.handleMatrixReaction(nextEvent, targetChannel);
|
||||
break;
|
||||
case 'm.room.canonical_alias':
|
||||
this.updateRoomName(nextEvent, targetChannel);
|
||||
break;
|
||||
case 'm.room.guest_access':
|
||||
this.handleMatrixGuestAccess(nextEvent, targetChannel);
|
||||
break;
|
||||
case 'm.room.history_visibility':
|
||||
this.handleMatrixHistoryVisibility(nextEvent, targetChannel);
|
||||
break;
|
||||
case 'm.room.join_rules':
|
||||
this.handleMatrixJoinRule(nextEvent, targetChannel);
|
||||
break;
|
||||
case 'm.room.member':
|
||||
this.handleMatrixMember(nextEvent, targetChannel);
|
||||
break;
|
||||
case 'm.room.message': {
|
||||
this.handleMatrixMessage(nextEvent, targetChannel);
|
||||
break;
|
||||
}
|
||||
case 'm.room.power_levels':
|
||||
this.handleMatrixPL(nextEvent, targetChannel);
|
||||
break;
|
||||
case 'm.room.redaction':
|
||||
this.handleMatrixRedaction(nextEvent, targetChannel);
|
||||
break;
|
||||
case 'm.room.topic':
|
||||
this.handleMatrixTopic(nextEvent, targetChannel);
|
||||
break;
|
||||
// Add some events we aren't going to use now (or ever)
|
||||
case 'm.room.name':
|
||||
case 'm.room.create':
|
||||
case 'uk.half-shot.bridge':
|
||||
case 'org.matrix.appservice-irc.config':
|
||||
case 'org.matrix.appservice-irc.connection':
|
||||
case 'im.vector.modular.widgets':
|
||||
case 'm.room.avatar':
|
||||
case 'm.room.third_party_invite':
|
||||
case 'm.room.related_groups':
|
||||
case 'm.room.bot.options':
|
||||
case 'm.room.pinned_events':
|
||||
case 'm.room.tombstone':
|
||||
case 'm.room.server_acl':
|
||||
case 'org.matrix.room.preview_urls':
|
||||
case 'm.space.child':
|
||||
case 'm.space.parent':
|
||||
case 'm.room.plumbing':
|
||||
case 'm.room.bridging':
|
||||
case 'org.matrix.confbot.auditorium':
|
||||
case 'org.matrix.confbot.child':
|
||||
case 'org.matrix.confbot.parent':
|
||||
case 'org.matrix.confbot.space':
|
||||
case 'org.matrix.confbot.interest_room':
|
||||
case 'io.element.widgets.layout':
|
||||
case 'org.matrix.msc3381.poll.response':
|
||||
case 'org.matrix.msc3381.poll.start':
|
||||
break;
|
||||
default:
|
||||
console.log(`${targetChannel.name}: ${nextEvent}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleMatrixReaction(event: any, targetChannel: Channel) {
|
||||
const sourceUser = this.getOrCreateMatrixUser(event["sender"]);
|
||||
if (!targetChannel.matrixUsers.has(sourceUser.nick)) {
|
||||
targetChannel.matrixUsers.set(sourceUser.nick, sourceUser);
|
||||
const prefix = sourceUser.getMask();
|
||||
const joinTags = new Map([["account", sourceUser.accountName], ['time', new Date(event["origin_server_ts"]).toISOString()]])
|
||||
this.sendToAllWithCap('extended-join', prefix, "JOIN", [targetChannel.name, sourceUser.accountName, sourceUser.realname], joinTags);
|
||||
this.sendToAllWithoutCap('extended-join', prefix, "JOIN", [targetChannel.name], joinTags);
|
||||
}
|
||||
const tags: Map<string, string> = new Map();
|
||||
tags.set('msgid', event["event_id"]);
|
||||
tags.set('account', sourceUser.accountName);
|
||||
tags.set('time', new Date(event["origin_server_ts"]).toISOString())
|
||||
const reactionData = event["content"]?.['m.relates_to'];
|
||||
if (!reactionData)
|
||||
return;
|
||||
|
||||
const targetMsgid = reactionData["event_id"];
|
||||
const reaction = reactionData["key"];
|
||||
tags.set('+draft/reply', targetMsgid);
|
||||
tags.set('+draft/react', reaction);
|
||||
this.sendToAllWithCap("message-tags", sourceUser.getMask(), "TAGMSG", [targetChannel.name], tags)
|
||||
}
|
||||
|
||||
updateRoomName(newNameEvent: any, targetChannel: Channel) {
|
||||
let newName: string = newNameEvent["content"]["alias"];
|
||||
if (newName === targetChannel.name)
|
||||
return;
|
||||
if (!newName || newName === "")
|
||||
newName = targetChannel.roomId;
|
||||
const oldName = targetChannel.name;
|
||||
this.channels.delete(oldName);
|
||||
targetChannel.name = newName;
|
||||
this.channels.set(newName, targetChannel);
|
||||
this.clients.forEach(client => {
|
||||
if (client.enabledCaps.has("draft/channel-rename")) {
|
||||
client.sendMessage(this.server.name, "RENAME", [oldName, targetChannel.name, "New channel name set"], new Map());
|
||||
}
|
||||
else {
|
||||
client.sendMessage(this.getMask(), "PART", [oldName, `Renaming channel to ${newName}`], new Map());
|
||||
this.joinNewIRCClient(client, targetChannel);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
handleMatrixGuestAccess(event: any, targetChannel: Channel) {
|
||||
const rule = event["content"]?.["guest_access"];
|
||||
if (!rule) {
|
||||
console.log(`Warning: Guest access not found in ${event}`);
|
||||
return;
|
||||
}
|
||||
targetChannel.guestAccess = rule;
|
||||
}
|
||||
|
||||
handleMatrixHistoryVisibility(event: any, targetChannel: Channel) {
|
||||
const rule = event["content"]?.["history_visibility"];
|
||||
if (!rule) {
|
||||
console.log(`Warning: history visibility not found in ${event}`);
|
||||
return;
|
||||
}
|
||||
targetChannel.historyVisibility = rule;
|
||||
}
|
||||
|
||||
handleMatrixJoinRule(event: any, targetChannel: Channel) {
|
||||
const rule = event["content"]?.["join_rule"];
|
||||
if (!rule) {
|
||||
console.log(`Warning: join rule not found in ${event}`);
|
||||
return;
|
||||
}
|
||||
targetChannel.joinRules = rule;
|
||||
}
|
||||
|
||||
handleMatrixMember(event: any, targetChannel: Channel) {
|
||||
const targetUser = this.getOrCreateMatrixUser(event["state_key"]);
|
||||
const sourceUser = this.getOrCreateMatrixUser(event["sender"]);
|
||||
const content = event["content"];
|
||||
if (!content)
|
||||
return;
|
||||
|
||||
const membershipStatus = content["membership"];
|
||||
const messageTags = new Map();
|
||||
messageTags.set('time', new Date(event["origin_server_ts"]).toISOString());
|
||||
messageTags.set('account', sourceUser.accountName);
|
||||
if (membershipStatus === "invite") {
|
||||
const reason = content["reason"];
|
||||
this.clients.forEach(c => {
|
||||
if (c.enabledCaps.has('invite-notify')) {
|
||||
if (c.enabledCaps.has('draft/extended-invite')) {
|
||||
c.sendMessage(sourceUser.getMask(), 'INVITE', [targetUser.nick, targetChannel.name, reason], messageTags)
|
||||
} else {
|
||||
c.sendMessage(sourceUser.getMask(), 'INVITE', [targetUser.nick, targetChannel.name], messageTags)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (membershipStatus === "join") {
|
||||
if (!targetChannel.matrixUsers.has(targetUser.nick)) {
|
||||
targetChannel.matrixUsers.set(targetUser.nick, targetUser);
|
||||
const prefix = targetUser.getMask();
|
||||
const joinTags = new Map([["account", targetUser.accountName], ['time', new Date(event["origin_server_ts"]).toISOString()]])
|
||||
this.sendToAllWithCap('extended-join', prefix, "JOIN", [targetChannel.name, targetUser.accountName, targetUser.realname], joinTags);
|
||||
this.sendToAllWithoutCap('extended-join', prefix, "JOIN", [targetChannel.name], joinTags);
|
||||
}
|
||||
}
|
||||
else if (membershipStatus === "leave") {
|
||||
if (!targetChannel.matrixUsers.has(targetUser.nick))
|
||||
return;
|
||||
if (targetUser.mxid === sourceUser.mxid) {
|
||||
const reason = content["reason"] || 'User left';
|
||||
this.clients.forEach((client) => {
|
||||
client.sendMessage(sourceUser.getMask(), 'PART', [targetChannel.name, reason], messageTags);
|
||||
});
|
||||
}
|
||||
else {
|
||||
const reason = content["reason"] || 'User was kicked';
|
||||
this.clients.forEach((client) => {
|
||||
client.sendMessage(sourceUser.getMask(), 'KICK', [targetChannel.name, targetUser.nick, reason], messageTags);
|
||||
});
|
||||
}
|
||||
targetChannel.matrixUsers.delete(targetUser.nick)
|
||||
}
|
||||
else if (membershipStatus === "ban") {
|
||||
if (!targetChannel.matrixUsers.has(targetUser.nick))
|
||||
return;
|
||||
const reason = content["reason"] || 'User was banned';
|
||||
this.clients.forEach((channel) => {
|
||||
channel.sendMessage(sourceUser.getMask(), 'KICK', [targetChannel.name, targetUser.nick, reason], messageTags);
|
||||
});
|
||||
targetChannel.matrixUsers.delete(targetUser.nick)
|
||||
}
|
||||
else {
|
||||
console.log(`Got unknown m.room.member event: ${event}`);
|
||||
}
|
||||
}
|
||||
|
||||
handleMatrixMessage(event: any, targetChannel: Channel) {
|
||||
const sourceUser = this.getOrCreateMatrixUser(event["sender"]);
|
||||
if (!targetChannel.matrixUsers.has(sourceUser.nick)) {
|
||||
targetChannel.matrixUsers.set(sourceUser.nick, sourceUser);
|
||||
const prefix = sourceUser.getMask();
|
||||
const joinTags = new Map([["account", sourceUser.accountName], ['time', new Date(event["origin_server_ts"]).toISOString()]])
|
||||
this.sendToAllWithCap('extended-join', prefix, "JOIN", [targetChannel.name, sourceUser.accountName, sourceUser.realname], joinTags);
|
||||
this.sendToAllWithoutCap('extended-join', prefix, "JOIN", [targetChannel.name], joinTags);
|
||||
}
|
||||
const content = event["content"];
|
||||
const msgtype = content["msgtype"];
|
||||
let messageContent = content["body"];
|
||||
const tags: Map<string, string> = new Map();
|
||||
tags.set('msgid', event["event_id"]);
|
||||
tags.set('account', sourceUser.accountName);
|
||||
tags.set('time', new Date(event["origin_server_ts"]).toISOString())
|
||||
const maybeReply = content["m.relates_to"]?.["m.in_reply_to"]?.["event_id"];
|
||||
const maybeTxnId: string = event["unsigned"]?.["transaction_id"] || "";
|
||||
if (maybeReply) {
|
||||
tags.set('+draft/reply', maybeReply);
|
||||
}
|
||||
const ircCommand = (msgtype === 'm.notice') ? 'NOTICE' : 'PRIVMSG';
|
||||
if (msgtype === 'm.emote') {
|
||||
messageContent = `\x01ACTION ${messageContent}\x01`;
|
||||
}
|
||||
else if (['m.image', 'm.file', 'm.audio', 'm.video'].includes(msgtype)) {
|
||||
let uri: string = content["url"];
|
||||
if (!uri)
|
||||
return;
|
||||
const mxcregex = uri.match(/mxc:\/\/(?<servername>[^\/]+)\/(?<mediaid>.+)/)
|
||||
if (!mxcregex || !mxcregex.groups)
|
||||
console.log(`Failed to parse MXC URI: ${uri}`);
|
||||
else
|
||||
uri = `https://${this.homeserver}/_matrix/media/v3/download/${mxcregex.groups.servername}/${mxcregex.groups.mediaid}`;
|
||||
messageContent = `\x01ACTION shared ${messageContent}: ${uri}\x01`;
|
||||
}
|
||||
const msgArray = (messageContent.indexOf('\n') !== -1) ? messageContent.split('\n'): [messageContent];
|
||||
msgArray.forEach((msg: string) => {
|
||||
if (msg) {
|
||||
this.clients.forEach((client) => {
|
||||
if (this.txnIdStore.get(maybeTxnId) === client) {
|
||||
if (client.enabledCaps.has('echo-message')) {
|
||||
client.sendMessage(sourceUser.getMask(), ircCommand, [targetChannel.name, msg], tags)
|
||||
}
|
||||
|
||||
} else {
|
||||
client.sendMessage(sourceUser.getMask(), ircCommand, [targetChannel.name, msg], tags)
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
convertPLToMode(pl: number, direction: string) {
|
||||
let modeString: string = "";
|
||||
if (pl > 99) modeString = `${direction}o`
|
||||
else if (pl > 49) modeString = `${direction}h`
|
||||
else if (pl > 0) modeString = `${direction}v`
|
||||
return modeString;
|
||||
}
|
||||
|
||||
handleMatrixPL(event: any, targetChannel: Channel) {
|
||||
const allUsers = event["content"]["users"];
|
||||
const sourceUser = this.getOrCreateMatrixUser(event["sender"]);
|
||||
if (!allUsers) return;
|
||||
|
||||
for (const [mxid, pl] of Object.entries(allUsers)) {
|
||||
const thisMatrixUser = this.getOrCreateMatrixUser(mxid);
|
||||
targetChannel.matrixUsers.set(thisMatrixUser.nick, thisMatrixUser);
|
||||
const oldPl = targetChannel.powerLevels.get(thisMatrixUser.nick);
|
||||
const newPl = Number(pl);
|
||||
if (oldPl === undefined) {
|
||||
targetChannel.powerLevels.set(thisMatrixUser.nick, newPl);
|
||||
const modeChange = this.convertPLToMode(newPl, "+");
|
||||
this.clients.forEach(c => {
|
||||
c.sendMessage(sourceUser.getMask(), "MODE", [targetChannel.name, modeChange, thisMatrixUser.nick]);
|
||||
})
|
||||
}
|
||||
else if (oldPl !== newPl) {
|
||||
const oldModeChange = this.convertPLToMode(oldPl, "-");
|
||||
this.clients.forEach(c => {
|
||||
c.sendMessage(sourceUser.getMask(), "MODE", [targetChannel.name, oldModeChange, thisMatrixUser.nick]);
|
||||
})
|
||||
if (newPl !== 0) {
|
||||
const newModeChange = this.convertPLToMode(newPl, "+");
|
||||
this.clients.forEach(c => {
|
||||
c.sendMessage(sourceUser.getMask(), "MODE", [targetChannel.name, newModeChange, thisMatrixUser.nick]);
|
||||
})
|
||||
} else {
|
||||
targetChannel.powerLevels.delete(thisMatrixUser.nick)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targetChannel.powerLevels.size !== Object.keys(allUsers).length) {
|
||||
for (const pl of targetChannel.powerLevels.keys()) {
|
||||
const nextUser = this.nickToMatrixUser.get(pl);
|
||||
if (!nextUser) return;
|
||||
if (!(nextUser.mxid in allUsers)) {
|
||||
const oldPl = targetChannel.powerLevels.get(pl);
|
||||
if (!oldPl) return;
|
||||
const oldMode = this.convertPLToMode(oldPl, "-");
|
||||
this.clients.forEach(c => {
|
||||
c.sendMessage(sourceUser.getMask(), "MODE", [targetChannel.name, oldMode, nextUser.nick]);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleMatrixRedaction(event: any, targetChannel: Channel) {
|
||||
const sourceUser = this.getOrCreateMatrixUser(event["sender"]);
|
||||
if (!targetChannel.matrixUsers.has(sourceUser.nick)) {
|
||||
targetChannel.matrixUsers.set(sourceUser.nick, sourceUser);
|
||||
const prefix = sourceUser.getMask();
|
||||
const joinTags = new Map([["account", sourceUser.accountName], ['time', new Date(event["origin_server_ts"]).toISOString()]])
|
||||
this.sendToAllWithCap('extended-join', prefix, "JOIN", [targetChannel.name, sourceUser.accountName, sourceUser.realname], joinTags);
|
||||
this.sendToAllWithoutCap('extended-join', prefix, "JOIN", [targetChannel.name], joinTags);
|
||||
}
|
||||
const reason = event["content"]?.["reason"] || "";
|
||||
const tags: Map<string, string> = new Map();
|
||||
tags.set('draft/delete-message', event["redacts"]);
|
||||
tags.set('account', sourceUser.accountName);
|
||||
tags.set('time', new Date(event["origin_server_ts"]).toISOString())
|
||||
this.clients.forEach((client) => {
|
||||
if (client.enabledCaps.has("draft/edit-message"))
|
||||
client.sendMessage(sourceUser.getMask(), 'DELETEMSG', [targetChannel.name, reason], tags);
|
||||
});
|
||||
}
|
||||
|
||||
handleMatrixTopic(event: any, targetChannel: Channel) {
|
||||
const topicText = event["content"]?.["topic"];
|
||||
if (!topicText)
|
||||
return;
|
||||
const topicSetter = this.getOrCreateMatrixUser(event["sender"]);
|
||||
const topicTS: string = event["origin_server_ts"].toString();
|
||||
targetChannel.topic.set("text", topicText);
|
||||
targetChannel.topic.set("timestamp", topicTS.substring(0,10))
|
||||
targetChannel.topic.set('setter', topicSetter.nick);
|
||||
|
||||
const messageTags = new Map();
|
||||
messageTags.set('msgid', event["event_id"]);
|
||||
messageTags.set('account', topicSetter.accountName);
|
||||
messageTags.set('time', new Date(event["origin_server_ts"]).toISOString())
|
||||
this.clients.forEach((client) => {
|
||||
client.sendMessage(topicSetter.getMask(), 'TOPIC', [targetChannel.name, topicText], messageTags);
|
||||
});
|
||||
}
|
||||
|
||||
sendToAll(prefix: string, command: string, params: string[], tags: Map<string, string> = new Map(), skipClient: Client|null = null) {
|
||||
this.clients.forEach(client => {
|
||||
if (client !== skipClient) {
|
||||
client.sendMessage(prefix, command, params, tags);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
sendToAllWithCap(cap: string, prefix: string, command: string, params: string[], tags: Map<string, string> = new Map(), skipClient: Client|null = null) {
|
||||
this.clients.forEach(client => {
|
||||
if (client !== skipClient && client.enabledCaps.has(cap)) {
|
||||
client.sendMessage(prefix, command, params, tags);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
sendToAllWithoutCap(cap: string, prefix: string, command: string, params: string[], tags: Map<string, string> = new Map(), skipClient: Client|null = null) {
|
||||
this.clients.forEach(client => {
|
||||
if (client !== skipClient && !client.enabledCaps.has(cap)) {
|
||||
client.sendMessage(prefix, command, params, tags);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
560
src/Server.ts
560
src/Server.ts
|
@ -1,18 +1,560 @@
|
|||
import { IRCUser } from "./IRCUser.js"
|
||||
import axios, { Axios } from "axios";
|
||||
import { Channel } from "./Channel.js";
|
||||
import { Client } from "./Client.js";
|
||||
import { MatrixUser } from "./MatrixUser.js";
|
||||
|
||||
export class Server {
|
||||
public homeserver: string
|
||||
public mxid: string
|
||||
public name: string
|
||||
public ircUsers: Map<string, IRCUser>
|
||||
public apiCall: Axios
|
||||
public channels: Map<string, Channel>
|
||||
public roomIdToChannel: Map<string, Channel>
|
||||
public directRooms: Map<string, string[]>
|
||||
private syncLocks: Set<Channel>
|
||||
private directMessages: Set<string>
|
||||
private matrixUsers: Map<string, MatrixUser>
|
||||
public ourMatrixUser: MatrixUser;
|
||||
private clients: Set<Client>
|
||||
public nickToMatrixUser: Map<string, MatrixUser>
|
||||
public txnIdStore: Map<string, Client>
|
||||
private nextBatch: string
|
||||
private isSyncing: boolean
|
||||
private initialSync: boolean
|
||||
private currentSyncTime: number
|
||||
constructor(public config: any) {
|
||||
this.name = this.config.serverName;
|
||||
this.ircUsers = new Map();
|
||||
this.homeserver = config.homeserver;
|
||||
this.mxid = config.mxid;
|
||||
this.name = config.serverName;
|
||||
this.apiCall = axios.create({
|
||||
baseURL: `${this.homeserver}/_matrix/client/v3`,
|
||||
timeout: 180000,
|
||||
headers: {"Authorization": `Bearer ${this.config.accessToken}`}
|
||||
})
|
||||
this.channels = new Map();
|
||||
this.roomIdToChannel = new Map();
|
||||
this.directRooms = new Map();
|
||||
this.syncLocks = new Set();
|
||||
this.directMessages = new Set();
|
||||
this.matrixUsers = new Map();
|
||||
this.nickToMatrixUser = new Map();
|
||||
this.clients = new Set();
|
||||
this.txnIdStore = new Map();
|
||||
this.nextBatch = "";
|
||||
this.isSyncing = false;
|
||||
this.initialSync = false;
|
||||
this.currentSyncTime = 0;
|
||||
setInterval(this.doSync.bind(this), 2000);
|
||||
this.ourMatrixUser = this.getOrCreateMatrixUser(this.mxid);
|
||||
this.apiCall.get("/account/whoami").then(r => {
|
||||
this.doLog("Authentication successful, starting initial sync");
|
||||
this.doSync();
|
||||
}).catch(e => {
|
||||
console.log(e);
|
||||
})
|
||||
}
|
||||
|
||||
doLog(message: string) {
|
||||
console.log(`[${new Date().toISOString()}] ${this.mxid}: ${message}`);
|
||||
}
|
||||
|
||||
doSync(): void {
|
||||
if (this.isSyncing) {
|
||||
if ((Date.now() - this.currentSyncTime) > 20000 && this.currentSyncTime > 0)
|
||||
this.doLog(`Sync is lagging, current sync has been running for ${Date.now() - this.currentSyncTime} milliseconds`);
|
||||
return;
|
||||
}
|
||||
this.isSyncing = true;
|
||||
const endpoint = (this.nextBatch === "") ? "/sync" : `/sync?since=${this.nextBatch}&timeout=15000`;
|
||||
this.currentSyncTime = Date.now();
|
||||
this.apiCall.get(endpoint).then(response => {
|
||||
const data = response.data;
|
||||
this.nextBatch = data.next_batch;
|
||||
const rooms = data.rooms;
|
||||
if (rooms && rooms['join']) {
|
||||
for (const roomId of Object.keys(rooms.join)) {
|
||||
const targetChannel = this.getOrCreateIRCChannel(roomId);
|
||||
rooms.join[roomId].timeline.events.forEach((nextEvent: any) => {
|
||||
if (targetChannel.isSynced())
|
||||
this.routeMatrixEvent(nextEvent, targetChannel);
|
||||
});
|
||||
}
|
||||
}
|
||||
this.isSyncing = false;
|
||||
}).catch((error) => {
|
||||
if (error.response) {
|
||||
this.doLog(`Error: ${error.response.status} ${error.response.statusText}`)
|
||||
} else {
|
||||
this.doLog(`Error: ${error}`);
|
||||
}
|
||||
this.isSyncing = false;
|
||||
});
|
||||
}
|
||||
|
||||
getOrCreateIRCUser(mxid: string, accessToken: string, homeserver: string): IRCUser {
|
||||
const maybeUser = this.ircUsers.get(mxid);
|
||||
if (maybeUser) {
|
||||
return maybeUser;
|
||||
getDirectMessages() {
|
||||
this.apiCall.get(`/user/${this.mxid}/account_data/m.direct`).then(response => {
|
||||
if (!response.data)
|
||||
return;
|
||||
//@ts-ignore
|
||||
Object.entries(response.data).forEach(m => this.directRooms.set(m[0], m[1]));
|
||||
}).catch(e => {
|
||||
const errcode = e.response?.data?.errcode;
|
||||
if (errcode !== "M_NOT_FOUND")
|
||||
this.doLog(`Error: ${e}`);
|
||||
})
|
||||
}
|
||||
|
||||
getOrCreateIRCChannel(roomId: string): Channel {
|
||||
const maybeChannel = this.roomIdToChannel.get(roomId);
|
||||
if (maybeChannel)
|
||||
return maybeChannel;
|
||||
|
||||
const newChannel = new Channel(roomId, this);
|
||||
this.syncLocks.add(newChannel);
|
||||
this.roomIdToChannel.set(roomId, newChannel);
|
||||
this.channels.set(roomId, newChannel);
|
||||
return newChannel;
|
||||
}
|
||||
|
||||
finishChannelSync(targetChannel: Channel) {
|
||||
this.syncLocks.delete(targetChannel);
|
||||
this.channels.delete(targetChannel.roomId);
|
||||
this.channels.set(targetChannel.name, targetChannel);
|
||||
this.roomIdToChannel.set(targetChannel.roomId, targetChannel);
|
||||
this.clients.forEach(c => this.joinNewIRCClient(c, targetChannel));
|
||||
if (this.initialSync === false && this.syncLocks.size === 0) {
|
||||
this.initialSync = true;
|
||||
this.doLog('Synced to network!');
|
||||
}
|
||||
return new IRCUser(mxid, accessToken, homeserver, this);
|
||||
}
|
||||
|
||||
getOrCreateMatrixUser(mxid: string): MatrixUser {
|
||||
let maybeMatrixUser = this.matrixUsers.get(mxid);
|
||||
if (maybeMatrixUser) {
|
||||
return maybeMatrixUser;
|
||||
}
|
||||
const localPart = mxid.split(":")[0].substring(1)
|
||||
if (!this.nickToMatrixUser.has(localPart)) {
|
||||
const newMatrixUser = new MatrixUser(mxid, localPart);
|
||||
this.matrixUsers.set(mxid, newMatrixUser);
|
||||
this.nickToMatrixUser.set(localPart, newMatrixUser);
|
||||
return newMatrixUser;
|
||||
}
|
||||
const homeserver = mxid.split(":")[1];
|
||||
const homeserverArray = homeserver.split('.');
|
||||
const baseDomainNum = homeserverArray.length - 2;
|
||||
let potentialNick = `${localPart}-${homeserverArray[baseDomainNum]}`;
|
||||
if (!this.nickToMatrixUser.has(potentialNick)) {
|
||||
const newMatrixUser = new MatrixUser(mxid, potentialNick);
|
||||
this.matrixUsers.set(mxid, newMatrixUser);
|
||||
this.nickToMatrixUser.set(potentialNick, newMatrixUser);
|
||||
return newMatrixUser;
|
||||
}
|
||||
potentialNick = `${localPart}-${homeserver}`;
|
||||
const newMatrixUser = new MatrixUser(mxid, potentialNick);
|
||||
this.matrixUsers.set(mxid, newMatrixUser);
|
||||
this.nickToMatrixUser.set(potentialNick, newMatrixUser);
|
||||
return newMatrixUser;
|
||||
}
|
||||
|
||||
getMask(): string {
|
||||
return this.ourMatrixUser.getMask();
|
||||
}
|
||||
|
||||
getClients(): Set<Client> {
|
||||
return this.clients;
|
||||
}
|
||||
|
||||
joinNewIRCClient(client: Client, targetChannel: Channel) {
|
||||
if (client.enabledCaps.has('extended-join')) {
|
||||
client.sendMessage(this.getMask(), "JOIN", [targetChannel.name, this.ourMatrixUser.accountName, this.mxid], new Map([['account', this.ourMatrixUser.realname]]));
|
||||
} else {
|
||||
client.sendMessage(this.getMask(), "JOIN", [targetChannel.name], new Map([['account', this.ourMatrixUser.realname]]));
|
||||
}
|
||||
client.sendNAMES(targetChannel);
|
||||
client.sendTOPIC(targetChannel);
|
||||
}
|
||||
|
||||
addClient(client: Client) {
|
||||
this.clients.add(client);
|
||||
if (this.initialSync) {
|
||||
for (const channel of this.channels.values()) {
|
||||
this.joinNewIRCClient(client, channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
routeMatrixEvent(nextEvent: any, targetChannel: Channel) {
|
||||
switch (nextEvent["type"]) {
|
||||
case 'm.reaction':
|
||||
this.handleMatrixReaction(nextEvent, targetChannel);
|
||||
break;
|
||||
case 'm.room.canonical_alias':
|
||||
this.updateRoomName(nextEvent, targetChannel);
|
||||
break;
|
||||
case 'm.room.guest_access':
|
||||
this.handleMatrixGuestAccess(nextEvent, targetChannel);
|
||||
break;
|
||||
case 'm.room.history_visibility':
|
||||
this.handleMatrixHistoryVisibility(nextEvent, targetChannel);
|
||||
break;
|
||||
case 'm.room.join_rules':
|
||||
this.handleMatrixJoinRule(nextEvent, targetChannel);
|
||||
break;
|
||||
case 'm.room.member':
|
||||
this.handleMatrixMember(nextEvent, targetChannel);
|
||||
break;
|
||||
case 'm.room.message': {
|
||||
this.handleMatrixMessage(nextEvent, targetChannel);
|
||||
break;
|
||||
}
|
||||
case 'm.room.power_levels':
|
||||
this.handleMatrixPL(nextEvent, targetChannel);
|
||||
break;
|
||||
case 'm.room.redaction':
|
||||
this.handleMatrixRedaction(nextEvent, targetChannel);
|
||||
break;
|
||||
case 'm.room.topic':
|
||||
this.handleMatrixTopic(nextEvent, targetChannel);
|
||||
break;
|
||||
// Add some events we aren't going to use now (or ever)
|
||||
case 'm.room.name':
|
||||
case 'm.room.create':
|
||||
case 'uk.half-shot.bridge':
|
||||
case 'org.matrix.appservice-irc.config':
|
||||
case 'org.matrix.appservice-irc.connection':
|
||||
case 'im.vector.modular.widgets':
|
||||
case 'm.room.avatar':
|
||||
case 'm.room.third_party_invite':
|
||||
case 'm.room.related_groups':
|
||||
case 'm.room.bot.options':
|
||||
case 'm.room.pinned_events':
|
||||
case 'm.room.tombstone':
|
||||
case 'm.room.server_acl':
|
||||
case 'org.matrix.room.preview_urls':
|
||||
case 'm.space.child':
|
||||
case 'm.space.parent':
|
||||
case 'm.room.plumbing':
|
||||
case 'm.room.bridging':
|
||||
case 'org.matrix.confbot.auditorium':
|
||||
case 'org.matrix.confbot.child':
|
||||
case 'org.matrix.confbot.parent':
|
||||
case 'org.matrix.confbot.space':
|
||||
case 'org.matrix.confbot.interest_room':
|
||||
case 'io.element.widgets.layout':
|
||||
case 'org.matrix.msc3381.poll.response':
|
||||
case 'org.matrix.msc3381.poll.start':
|
||||
break;
|
||||
default:
|
||||
console.log(`${targetChannel.name}: ${nextEvent}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleMatrixReaction(event: any, targetChannel: Channel) {
|
||||
const sourceUser = this.getOrCreateMatrixUser(event["sender"]);
|
||||
if (!targetChannel.matrixUsers.has(sourceUser.nick)) {
|
||||
targetChannel.matrixUsers.set(sourceUser.nick, sourceUser);
|
||||
const prefix = sourceUser.getMask();
|
||||
const joinTags = new Map([["account", sourceUser.accountName], ['time', new Date(event["origin_server_ts"]).toISOString()]])
|
||||
this.sendToAllWithCap('extended-join', prefix, "JOIN", [targetChannel.name, sourceUser.accountName, sourceUser.realname], joinTags);
|
||||
this.sendToAllWithoutCap('extended-join', prefix, "JOIN", [targetChannel.name], joinTags);
|
||||
}
|
||||
const tags: Map<string, string> = new Map();
|
||||
tags.set('msgid', event["event_id"]);
|
||||
tags.set('account', sourceUser.accountName);
|
||||
tags.set('time', new Date(event["origin_server_ts"]).toISOString())
|
||||
const reactionData = event["content"]?.['m.relates_to'];
|
||||
if (!reactionData)
|
||||
return;
|
||||
|
||||
const targetMsgid = reactionData["event_id"];
|
||||
const reaction = reactionData["key"];
|
||||
tags.set('+draft/reply', targetMsgid);
|
||||
tags.set('+draft/react', reaction);
|
||||
this.sendToAllWithCap("message-tags", sourceUser.getMask(), "TAGMSG", [targetChannel.name], tags)
|
||||
}
|
||||
|
||||
updateRoomName(newNameEvent: any, targetChannel: Channel) {
|
||||
let newName: string = newNameEvent["content"]["alias"];
|
||||
if (newName === targetChannel.name)
|
||||
return;
|
||||
if (!newName || newName === "")
|
||||
newName = targetChannel.roomId;
|
||||
const oldName = targetChannel.name;
|
||||
this.channels.delete(oldName);
|
||||
targetChannel.name = newName;
|
||||
this.channels.set(newName, targetChannel);
|
||||
this.clients.forEach(client => {
|
||||
if (client.enabledCaps.has("draft/channel-rename")) {
|
||||
client.sendMessage(this.name, "RENAME", [oldName, targetChannel.name, "New channel name set"], new Map());
|
||||
}
|
||||
else {
|
||||
client.sendMessage(this.getMask(), "PART", [oldName, `Renaming channel to ${newName}`], new Map());
|
||||
this.joinNewIRCClient(client, targetChannel);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
handleMatrixGuestAccess(event: any, targetChannel: Channel) {
|
||||
const rule = event["content"]?.["guest_access"];
|
||||
if (!rule) {
|
||||
console.log(`Warning: Guest access not found in ${event}`);
|
||||
return;
|
||||
}
|
||||
targetChannel.guestAccess = rule;
|
||||
}
|
||||
|
||||
handleMatrixHistoryVisibility(event: any, targetChannel: Channel) {
|
||||
const rule = event["content"]?.["history_visibility"];
|
||||
if (!rule) {
|
||||
console.log(`Warning: history visibility not found in ${event}`);
|
||||
return;
|
||||
}
|
||||
targetChannel.historyVisibility = rule;
|
||||
}
|
||||
|
||||
handleMatrixJoinRule(event: any, targetChannel: Channel) {
|
||||
const rule = event["content"]?.["join_rule"];
|
||||
if (!rule) {
|
||||
console.log(`Warning: join rule not found in ${event}`);
|
||||
return;
|
||||
}
|
||||
targetChannel.joinRules = rule;
|
||||
}
|
||||
|
||||
handleMatrixMember(event: any, targetChannel: Channel) {
|
||||
const targetUser = this.getOrCreateMatrixUser(event["state_key"]);
|
||||
const sourceUser = this.getOrCreateMatrixUser(event["sender"]);
|
||||
const content = event["content"];
|
||||
if (!content)
|
||||
return;
|
||||
|
||||
const membershipStatus = content["membership"];
|
||||
const messageTags = new Map();
|
||||
messageTags.set('time', new Date(event["origin_server_ts"]).toISOString());
|
||||
messageTags.set('account', sourceUser.accountName);
|
||||
if (membershipStatus === "invite") {
|
||||
const reason = content["reason"];
|
||||
this.clients.forEach(c => {
|
||||
if (c.enabledCaps.has('invite-notify')) {
|
||||
if (c.enabledCaps.has('draft/extended-invite')) {
|
||||
c.sendMessage(sourceUser.getMask(), 'INVITE', [targetUser.nick, targetChannel.name, reason], messageTags)
|
||||
} else {
|
||||
c.sendMessage(sourceUser.getMask(), 'INVITE', [targetUser.nick, targetChannel.name], messageTags)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (membershipStatus === "join") {
|
||||
if (!targetChannel.matrixUsers.has(targetUser.nick)) {
|
||||
targetChannel.matrixUsers.set(targetUser.nick, targetUser);
|
||||
const prefix = targetUser.getMask();
|
||||
const joinTags = new Map([["account", targetUser.accountName], ['time', new Date(event["origin_server_ts"]).toISOString()]])
|
||||
this.sendToAllWithCap('extended-join', prefix, "JOIN", [targetChannel.name, targetUser.accountName, targetUser.realname], joinTags);
|
||||
this.sendToAllWithoutCap('extended-join', prefix, "JOIN", [targetChannel.name], joinTags);
|
||||
}
|
||||
}
|
||||
else if (membershipStatus === "leave") {
|
||||
if (!targetChannel.matrixUsers.has(targetUser.nick))
|
||||
return;
|
||||
if (targetUser.mxid === sourceUser.mxid) {
|
||||
const reason = content["reason"] || 'User left';
|
||||
this.clients.forEach((client) => {
|
||||
client.sendMessage(sourceUser.getMask(), 'PART', [targetChannel.name, reason], messageTags);
|
||||
});
|
||||
}
|
||||
else {
|
||||
const reason = content["reason"] || 'User was kicked';
|
||||
this.clients.forEach((client) => {
|
||||
client.sendMessage(sourceUser.getMask(), 'KICK', [targetChannel.name, targetUser.nick, reason], messageTags);
|
||||
});
|
||||
}
|
||||
targetChannel.matrixUsers.delete(targetUser.nick)
|
||||
}
|
||||
else if (membershipStatus === "ban") {
|
||||
if (!targetChannel.matrixUsers.has(targetUser.nick))
|
||||
return;
|
||||
const reason = content["reason"] || 'User was banned';
|
||||
this.clients.forEach((channel) => {
|
||||
channel.sendMessage(sourceUser.getMask(), 'KICK', [targetChannel.name, targetUser.nick, reason], messageTags);
|
||||
});
|
||||
targetChannel.matrixUsers.delete(targetUser.nick)
|
||||
}
|
||||
else {
|
||||
console.log(`Got unknown m.room.member event: ${event}`);
|
||||
}
|
||||
}
|
||||
|
||||
handleMatrixMessage(event: any, targetChannel: Channel) {
|
||||
const sourceUser = this.getOrCreateMatrixUser(event["sender"]);
|
||||
if (!targetChannel.matrixUsers.has(sourceUser.nick)) {
|
||||
targetChannel.matrixUsers.set(sourceUser.nick, sourceUser);
|
||||
const prefix = sourceUser.getMask();
|
||||
const joinTags = new Map([["account", sourceUser.accountName], ['time', new Date(event["origin_server_ts"]).toISOString()]])
|
||||
this.sendToAllWithCap('extended-join', prefix, "JOIN", [targetChannel.name, sourceUser.accountName, sourceUser.realname], joinTags);
|
||||
this.sendToAllWithoutCap('extended-join', prefix, "JOIN", [targetChannel.name], joinTags);
|
||||
}
|
||||
const content = event["content"];
|
||||
const msgtype = content["msgtype"];
|
||||
let messageContent = content["body"];
|
||||
const tags: Map<string, string> = new Map();
|
||||
tags.set('msgid', event["event_id"]);
|
||||
tags.set('account', sourceUser.accountName);
|
||||
tags.set('time', new Date(event["origin_server_ts"]).toISOString())
|
||||
const maybeReply = content["m.relates_to"]?.["m.in_reply_to"]?.["event_id"];
|
||||
const maybeTxnId: string = event["unsigned"]?.["transaction_id"] || "";
|
||||
if (maybeReply) {
|
||||
tags.set('+draft/reply', maybeReply);
|
||||
}
|
||||
const ircCommand = (msgtype === 'm.notice') ? 'NOTICE' : 'PRIVMSG';
|
||||
if (msgtype === 'm.emote') {
|
||||
messageContent = `\x01ACTION ${messageContent}\x01`;
|
||||
}
|
||||
else if (['m.image', 'm.file', 'm.audio', 'm.video'].includes(msgtype)) {
|
||||
let uri: string = content["url"];
|
||||
if (!uri)
|
||||
return;
|
||||
const mxcregex = uri.match(/mxc:\/\/(?<servername>[^\/]+)\/(?<mediaid>.+)/)
|
||||
if (!mxcregex || !mxcregex.groups)
|
||||
console.log(`Failed to parse MXC URI: ${uri}`);
|
||||
else
|
||||
uri = `https://${this.homeserver}/_matrix/media/v3/download/${mxcregex.groups.servername}/${mxcregex.groups.mediaid}`;
|
||||
messageContent = `\x01ACTION shared ${messageContent}: ${uri}\x01`;
|
||||
}
|
||||
const msgArray = (messageContent.indexOf('\n') !== -1) ? messageContent.split('\n'): [messageContent];
|
||||
msgArray.forEach((msg: string) => {
|
||||
if (msg) {
|
||||
this.clients.forEach((client) => {
|
||||
if (this.txnIdStore.get(maybeTxnId) === client) {
|
||||
if (client.enabledCaps.has('echo-message')) {
|
||||
client.sendMessage(sourceUser.getMask(), ircCommand, [targetChannel.name, msg], tags)
|
||||
}
|
||||
|
||||
} else {
|
||||
client.sendMessage(sourceUser.getMask(), ircCommand, [targetChannel.name, msg], tags)
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
convertPLToMode(pl: number, direction: string) {
|
||||
let modeString: string = "";
|
||||
if (pl > 99) modeString = `${direction}o`
|
||||
else if (pl > 49) modeString = `${direction}h`
|
||||
else if (pl > 0) modeString = `${direction}v`
|
||||
return modeString;
|
||||
}
|
||||
|
||||
handleMatrixPL(event: any, targetChannel: Channel) {
|
||||
const allUsers = event["content"]["users"];
|
||||
const sourceUser = this.getOrCreateMatrixUser(event["sender"]);
|
||||
if (!allUsers) return;
|
||||
|
||||
for (const [mxid, pl] of Object.entries(allUsers)) {
|
||||
const thisMatrixUser = this.getOrCreateMatrixUser(mxid);
|
||||
targetChannel.matrixUsers.set(thisMatrixUser.nick, thisMatrixUser);
|
||||
const oldPl = targetChannel.powerLevels.get(thisMatrixUser.nick);
|
||||
const newPl = Number(pl);
|
||||
if (oldPl === undefined) {
|
||||
targetChannel.powerLevels.set(thisMatrixUser.nick, newPl);
|
||||
const modeChange = this.convertPLToMode(newPl, "+");
|
||||
this.clients.forEach(c => {
|
||||
c.sendMessage(sourceUser.getMask(), "MODE", [targetChannel.name, modeChange, thisMatrixUser.nick]);
|
||||
})
|
||||
}
|
||||
else if (oldPl !== newPl) {
|
||||
const oldModeChange = this.convertPLToMode(oldPl, "-");
|
||||
this.clients.forEach(c => {
|
||||
c.sendMessage(sourceUser.getMask(), "MODE", [targetChannel.name, oldModeChange, thisMatrixUser.nick]);
|
||||
})
|
||||
if (newPl !== 0) {
|
||||
const newModeChange = this.convertPLToMode(newPl, "+");
|
||||
this.clients.forEach(c => {
|
||||
c.sendMessage(sourceUser.getMask(), "MODE", [targetChannel.name, newModeChange, thisMatrixUser.nick]);
|
||||
})
|
||||
} else {
|
||||
targetChannel.powerLevels.delete(thisMatrixUser.nick)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targetChannel.powerLevels.size !== Object.keys(allUsers).length) {
|
||||
for (const pl of targetChannel.powerLevels.keys()) {
|
||||
const nextUser = this.nickToMatrixUser.get(pl);
|
||||
if (!nextUser) return;
|
||||
if (!(nextUser.mxid in allUsers)) {
|
||||
const oldPl = targetChannel.powerLevels.get(pl);
|
||||
if (!oldPl) return;
|
||||
const oldMode = this.convertPLToMode(oldPl, "-");
|
||||
this.clients.forEach(c => {
|
||||
c.sendMessage(sourceUser.getMask(), "MODE", [targetChannel.name, oldMode, nextUser.nick]);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleMatrixRedaction(event: any, targetChannel: Channel) {
|
||||
const sourceUser = this.getOrCreateMatrixUser(event["sender"]);
|
||||
if (!targetChannel.matrixUsers.has(sourceUser.nick)) {
|
||||
targetChannel.matrixUsers.set(sourceUser.nick, sourceUser);
|
||||
const prefix = sourceUser.getMask();
|
||||
const joinTags = new Map([["account", sourceUser.accountName], ['time', new Date(event["origin_server_ts"]).toISOString()]])
|
||||
this.sendToAllWithCap('extended-join', prefix, "JOIN", [targetChannel.name, sourceUser.accountName, sourceUser.realname], joinTags);
|
||||
this.sendToAllWithoutCap('extended-join', prefix, "JOIN", [targetChannel.name], joinTags);
|
||||
}
|
||||
const reason = event["content"]?.["reason"] || "";
|
||||
const tags: Map<string, string> = new Map();
|
||||
tags.set('draft/delete-message', event["redacts"]);
|
||||
tags.set('account', sourceUser.accountName);
|
||||
tags.set('time', new Date(event["origin_server_ts"]).toISOString())
|
||||
this.clients.forEach((client) => {
|
||||
if (client.enabledCaps.has("draft/edit-message"))
|
||||
client.sendMessage(sourceUser.getMask(), 'DELETEMSG', [targetChannel.name, reason], tags);
|
||||
});
|
||||
}
|
||||
|
||||
handleMatrixTopic(event: any, targetChannel: Channel) {
|
||||
const topicText = event["content"]?.["topic"];
|
||||
if (!topicText)
|
||||
return;
|
||||
const topicSetter = this.getOrCreateMatrixUser(event["sender"]);
|
||||
const topicTS: string = event["origin_server_ts"].toString();
|
||||
targetChannel.topic.set("text", topicText);
|
||||
targetChannel.topic.set("timestamp", topicTS.substring(0,10))
|
||||
targetChannel.topic.set('setter', topicSetter.nick);
|
||||
|
||||
const messageTags = new Map();
|
||||
messageTags.set('msgid', event["event_id"]);
|
||||
messageTags.set('account', topicSetter.accountName);
|
||||
messageTags.set('time', new Date(event["origin_server_ts"]).toISOString())
|
||||
this.clients.forEach((client) => {
|
||||
client.sendMessage(topicSetter.getMask(), 'TOPIC', [targetChannel.name, topicText], messageTags);
|
||||
});
|
||||
}
|
||||
|
||||
sendToAll(prefix: string, command: string, params: string[], tags: Map<string, string> = new Map(), skipClient: Client|null = null) {
|
||||
this.clients.forEach(client => {
|
||||
if (client !== skipClient) {
|
||||
client.sendMessage(prefix, command, params, tags);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
sendToAllWithCap(cap: string, prefix: string, command: string, params: string[], tags: Map<string, string> = new Map(), skipClient: Client|null = null) {
|
||||
this.clients.forEach(client => {
|
||||
if (client !== skipClient && client.enabledCaps.has(cap)) {
|
||||
client.sendMessage(prefix, command, params, tags);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
sendToAllWithoutCap(cap: string, prefix: string, command: string, params: string[], tags: Map<string, string> = new Map(), skipClient: Client|null = null) {
|
||||
this.clients.forEach(client => {
|
||||
if (client !== skipClient && !client.enabledCaps.has(cap)) {
|
||||
client.sendMessage(prefix, command, params, tags);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -8,6 +8,11 @@ const client = connect({
|
|||
port: config["port"],
|
||||
rejectUnauthorized: false
|
||||
}, () => {
|
||||
client.write("CAP LS 302\r\n");
|
||||
client.write("CAP REQ :account-tag batch draft/channel-rename echo-message extended-join invite-notify message-tags sasl server-time\r\n");
|
||||
const saslPassword = Buffer.from(`\0user\0${config.SASLPassword}`).toString('base64');
|
||||
client.write(`AUTHENTICATE ${saslPassword}\r\n`)
|
||||
client.write("CAP END\r\n")
|
||||
process.stdin.pipe(client);
|
||||
process.stdin.resume();
|
||||
})
|
||||
|
|
Loading…
Add table
Reference in a new issue