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 apiCall: Axios public channels: Map public invitedChannels: Set public roomIdToChannel: Map public directRooms: Map private syncLocks: Set private directMessages: Set private matrixUsers: Map public ourMatrixUser: MatrixUser; private clients: Set public nickToMatrixUser: Map public eventIdStore: Map public eventIDToLabel: Map private nextBatch: string private isSyncing: boolean private initialSync: boolean private currentSyncTime: number constructor(public config: any) { 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.invitedChannels = new Set(); 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.eventIdStore = new Map(); this.eventIDToLabel = 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.getDirectMessages(); 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) > this.config.lagWarningLimit && 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=${this.config.syncTimeout}`; 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) { if (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); }); } } if (rooms['invite']) { for (const roomId of Object.keys(rooms.invite)) { this.invitedChannels.add(roomId); } } } 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; }); } 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; this.getDirectMessages(); 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.invitedChannels.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!'); } } 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 { 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); } checkForLazyJoin(event: any, sourceUser: MatrixUser, targetChannel: Channel) { 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); } } addClient(client: Client) { this.clients.add(client); if (this.initialSync) { for (const channel of this.channels.values()) { this.joinNewIRCClient(client, channel); } this.invitedChannels.forEach(roomId => this.sendToAll(this.name, "INVITE", [this.ourMatrixUser.nick, roomId], new Map())); } } 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.encrypted': this.handleEncryptedMessage(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}:`); console.log(nextEvent); break; } } handleMatrixReaction(event: any, targetChannel: Channel) { const sourceUser = this.getOrCreateMatrixUser(event["sender"]); this.checkForLazyJoin(event, sourceUser, targetChannel); const tags: Map = 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); if (this.eventIDToLabel.has(event["event_id"])) { tags.set("label", this.eventIDToLabel.get(event["event_id"]) || "") } 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); } }) } handleEncryptedMessage(event: any, targetChannel: Channel) { const sourceUser = this.getOrCreateMatrixUser(event["sender"]); this.checkForLazyJoin(event, sourceUser, targetChannel); const messageTags = new Map(); messageTags.set('msgid', event["event_id"]); messageTags.set('time', new Date(event["origin_server_ts"]).toISOString()); messageTags.set('account', sourceUser.accountName); this.clients.forEach((channel) => { channel.sendMessage(sourceUser.getMask(), 'NOTICE', [targetChannel.name, "Sent an encrypted message, use another client to view"], messageTags); }); } 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('reflectionircd.chat/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") { this.checkForLazyJoin(event, sourceUser, targetChannel); } 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"]); this.checkForLazyJoin(event, sourceUser, targetChannel); const content = event["content"]; const msgtype = content["msgtype"]; let messageContent = content["body"]; const tags: Map = new Map(); tags.set('msgid', event["event_id"]); tags.set('account', sourceUser.accountName); tags.set('time', new Date(event["origin_server_ts"]).toISOString()) if (this.eventIDToLabel.has(event["event_id"])) { tags.set("label", this.eventIDToLabel.get(event["event_id"]) || "") } const maybeReply = content["m.relates_to"]?.["m.in_reply_to"]?.["event_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:\/\/(?[^\/]+)\/(?.+)/) if (!mxcregex || !mxcregex.groups) console.log(`Failed to parse MXC URI: ${uri}`); else uri = `${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.eventIdStore.get(event["event_id"]) === 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"]); this.checkForLazyJoin(event, sourceUser, targetChannel); const reason = event["content"]?.["reason"] || ""; const tags: Map = new Map(); tags.set('reflectionircd.chat/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("reflectionircd.chat/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.replace(/\r\n|\r|\n/, " ")); 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 = 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 = 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 = new Map(), skipClient: Client|null = null) { this.clients.forEach(client => { if (client !== skipClient && !client.enabledCaps.has(cap)) { client.sendMessage(prefix, command, params, tags); } }) } }