import axios, { Axios } from "axios"; import { readFileSync } from "fs"; import { createServer, Server } from "tls"; import { IRCClient } from "./IRCClient.js"; import { Channel } from "./Channel.js"; import { Client } from "./Client.js"; import { MatrixUser } from "./MatrixUser.js"; export class IRCServer { public config: any 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 client: Client public nickToMatrixUser: Map public eventIdStore: Map public eventIDToLabel: Map private nextBatch: string private isSyncing: boolean private initialSync: boolean private currentSyncTime: number private listener: Server; constructor() { this.config = JSON.parse(readFileSync(process.argv[2], {"encoding": "utf-8"})); this.homeserver = this.config.homeserver; this.mxid = this.config.mxid; this.name = this.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.client = new Client(this); 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.listener = createServer({ cert: readFileSync(this.config["certFile"]), key: readFileSync(this.config["keyFile"]) }); 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 => { this.doLog(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); }); rooms.join[roomId].ephemeral.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.joinNewIRCClient(this.client, targetChannel); if (this.initialSync === false && this.syncLocks.size === 0) { this.initialSync = true; this.doLog('Synced to network!'); this.listener.on('secureConnection', (c) => { new IRCClient(c, this); }) this.listener.listen(this.config["port"], () => { this.doLog(`Listening on port ${this.config["port"]}`); }) } } getOrCreateMatrixUser(mxid: string): MatrixUser { let maybeMatrixUser = this.matrixUsers.get(mxid); if (maybeMatrixUser) { return maybeMatrixUser; } const localPart = mxid.split(":")[0].substring(1).replace(' ', '-20'); 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(); } 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, new Map()); } 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()]]) if (this.client.enabledCaps.has('extended-join')) { this.client.sendMessage(prefix, "JOIN", [targetChannel.name, sourceUser.accountName, sourceUser.realname], joinTags); } else { this.client.sendMessage(prefix, "JOIN", [targetChannel.name], joinTags); } } } addClient(client: Client) { if (this.client.isRegistered) { this.client.closeConnectionWithError("New client connected"); } this.client = client; if (this.initialSync) { for (const channel of this.channels.values()) { this.joinNewIRCClient(client, channel); } this.invitedChannels.forEach(roomId => this.client.sendMessage(this.name, "INVITE", [this.ourMatrixUser.nick, roomId], new Map())); } } routeMatrixEvent(nextEvent: any, targetChannel: Channel) { switch (nextEvent["type"]) { case 'm.presence': this.handleMatrixPresence(nextEvent); break; 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.tombstone': this.handleMatrixTombstone(nextEvent,targetChannel); break; case 'm.room.topic': this.handleMatrixTopic(nextEvent, targetChannel); break; case 'm.sticker': this.handleMatrixSticker(nextEvent, targetChannel); break; case 'm.typing': this.handleMatrixTyping(nextEvent, targetChannel); break; // Add some events we aren't going to use now (or ever) case 'm.receipt': 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.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: this.doLog(`${targetChannel.name}:`); this.doLog(nextEvent); break; } } handleMatrixPresence(event: any) { let matrixUser = this.matrixUsers.get(event["sender"]); // Don't bother sending if the user isn't joined to any channels yet if (!matrixUser) { return; } if (matrixUser.mxid === this.ourMatrixUser.mxid) { return; } const presence = event["content"]?.["presence"]; if (!presence) { return; } const status = event["content"]?.["status_msg"]; if (status === undefined) { return; } if (presence === 'online') { if (this.client.enabledCaps.has('away-notify')) { this.client.sendMessage(matrixUser.getMask(), 'AWAY', []); } } else { if (this.client.enabledCaps.has('away-notify')) { const awayMessage = (status === '') ? "user is away" : status; this.client.sendMessage(matrixUser.getMask(), 'AWAY', [awayMessage]); } } } 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"]) || "") } if (this.client.enabledCaps.has('message-tags')) { this.client.sendMessage(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); if (this.client.enabledCaps.has("draft/channel-rename")) { this.client.sendMessage(this.name, "RENAME", [oldName, targetChannel.name, "New channel name set"], new Map()); } else { this.client.sendMessage(this.getMask(), "PART", [oldName, `Renaming channel to ${newName}`], new Map()); this.joinNewIRCClient(this.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.client.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) { this.doLog(`Warning: Guest access not found in ${event}`); return; } targetChannel.guestAccess = rule; } handleMatrixHistoryVisibility(event: any, targetChannel: Channel) { const rule = event["content"]?.["history_visibility"]; if (!rule) { this.doLog(`Warning: history visibility not found in ${event}`); return; } targetChannel.historyVisibility = rule; } handleMatrixJoinRule(event: any, targetChannel: Channel) { const rule = event["content"]?.["join_rule"]; if (!rule) { this.doLog(`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"]; if (this.client.enabledCaps.has('invite-notify')) { if (this.client.enabledCaps.has('reflectionircd.chat/extended-invite')) { this.client.sendMessage(sourceUser.getMask(), 'INVITE', [targetUser.nick, targetChannel.name, reason], messageTags) } else { this.client.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.client.sendMessage(sourceUser.getMask(), 'PART', [targetChannel.name, reason], messageTags); } else { const reason = content["reason"] || 'User was kicked'; this.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.client.sendMessage(sourceUser.getMask(), 'KICK', [targetChannel.name, targetUser.nick, reason], messageTags); targetChannel.matrixUsers.delete(targetUser.nick) } else { this.doLog(`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) this.doLog(`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]; if (msgArray.length === 1) { const msg = msgArray[0]; if (!msg) { return; } if (this.eventIdStore.get(event["event_id"]) === this.client) { if (this.client.enabledCaps.has('echo-message')) { this.client.sendMessage(sourceUser.getMask(), ircCommand, [targetChannel.name, msg], tags) } } else { this.client.sendMessage(sourceUser.getMask(), ircCommand, [targetChannel.name, msg], tags) } } else { if (this.eventIdStore.get(event["event_id"]) === this.client && !this.client.enabledCaps.has('echo-message')) { return; } if (this.client.enabledCaps.has('draft/multiline')) { const batchLabel = Math.random().toString(36).substring(2,7); const batchTags = new Map(); batchTags.set('batch', batchLabel); this.client.sendMessage(sourceUser.getMask(), 'BATCH', [`+${batchLabel}`, 'draft/multiline', targetChannel.name], tags); for (const msg of msgArray) { if (msg) { this.client.sendMessage(sourceUser.getMask(), ircCommand, [targetChannel.name, msg], batchTags) } } this.client.sendMessage(sourceUser.getMask(), 'BATCH', [`-${batchLabel}`], new Map()); } else { for (const msg of msgArray) { if (msg) { this.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.client.sendMessage(sourceUser.getMask(), "MODE", [targetChannel.name, modeChange, thisMatrixUser.nick]); } else if (oldPl !== newPl) { const oldModeChange = this.convertPLToMode(oldPl, "-"); this.client.sendMessage(sourceUser.getMask(), "MODE", [targetChannel.name, oldModeChange, thisMatrixUser.nick]); if (newPl !== 0) { const newModeChange = this.convertPLToMode(newPl, "+"); this.client.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.client.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()) if (this.client.enabledCaps.has("reflectionircd.chat/edit-message")) { this.client.sendMessage(sourceUser.getMask(), 'DELETEMSG', [targetChannel.name, reason], tags); } } handleMatrixTombstone(event: any, targetChannel: Channel) { const newRoom = event["content"]?.["replacement_room"]; if (!newRoom) return const message = `This room has been replaced by ${newRoom}, please join that room using another client` this.client.sendMessage(this.name, 'NOTICE', [targetChannel.name, message], new Map()); } 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()); if (this.eventIDToLabel.has(event["event_id"])) { messageTags.set("label", this.eventIDToLabel.get(event["event_id"]) || "") } this.client.sendMessage(topicSetter.getMask(), 'TOPIC', [targetChannel.name, topicText], messageTags); } handleMatrixSticker(event: any, targetChannel: Channel) { const sourceUser = this.getOrCreateMatrixUser(event["sender"]); this.checkForLazyJoin(event, sourceUser, targetChannel); const altText = event["content"]?.["body"]; const imgMxc = event["content"]?.["url"]; if (!altText || !imgMxc) { return; } const mxcregex = imgMxc.match(/mxc:\/\/(?[^\/]+)\/(?.+)/) let uri = imgMxc; if (!mxcregex || !mxcregex.groups) { this.doLog(`Failed to parse MXC URI: ${imgMxc}`); } else { uri = `${this.homeserver}/_matrix/media/v3/download/${mxcregex.groups.servername}/${mxcregex.groups.mediaid}`; } const messageContent = `\x01ACTION sent a sticker: ${altText}: ${uri}\x01`; this.client.sendMessage(sourceUser.getMask(), 'PRIVMSG', [targetChannel.name, messageContent]); } handleMatrixTyping(event: any, targetChannel: Channel) { const typingUsers = event["content"]?.["user_ids"]; if (!typingUsers) { return; } typingUsers.forEach((mxid: string) => { let matrixUser = this.matrixUsers.get(mxid); // Don't bother sending if the user isn't joined to the channel yet if (matrixUser !== undefined) { const typingTags = new Map(); typingTags.set('+typing', 'active'); if (this.client.enabledCaps.has('message-tags')) { this.client.sendMessage(matrixUser.getMask(), 'TAGMSG', [targetChannel.name], typingTags); } } }) } }