diff --git a/src/Channel.ts b/src/Channel.ts index c5e9b2e..1e9c02c 100644 --- a/src/Channel.ts +++ b/src/Channel.ts @@ -1,24 +1,18 @@ -import { Server } from "./Server.js"; import { MatrixUser } from "./MatrixUser.js"; -import { IRCUser } from "./IRCUser.js"; -import { Client } from "./Client.js"; -import numerics from "./numerics.js"; -import { IRCMessage } from "./Message.js"; export class Channel { public name: string public matrixUsers: Map - public ircUsers: Map - private powerLevels: Map - private topic: Map; - private eventIDsSeen: Set; - private historyVisibility: string - private guestAccess: string - private joinRules: string - constructor(public roomId: string, private server: Server) { + public powerLevels: Map + public topic: Map; + public eventIDsSeen: Set; + public historyVisibility: string + public guestAccess: string + public joinRules: string + constructor(public roomId: string, initialMatrixUser: MatrixUser) { this.name = roomId; this.matrixUsers = new Map(); - this.ircUsers = new Map(); + this.matrixUsers.set(initialMatrixUser.nick, initialMatrixUser); this.powerLevels = new Map(); this.topic = new Map([['text', ''], ['timestamp', '0'], ['setter', 'matrix']]); this.eventIDsSeen = new Set(); @@ -41,359 +35,4 @@ export class Channel { } return opStatus; } - - updateRoomName(newNameEvent: any) { - const newName: string = newNameEvent["content"]["alias"]; - if (!newName || newName === this.name) - return; - const oldName = this.name; - this.server.ircChannels.delete(oldName); - this.name = newName; - this.server.ircChannels.set(newName, this); - this.ircUsers.forEach((user, username) => { - user.getClients().forEach(client => { - if (client.enabledCaps.has("draft/channel-rename")) { - client.sendMessage(this.server.name, "RENAME", [oldName, this.name, "New channel name set"], new Map()); - } - else { - client.sendMessage(this.server.name, "PART", [oldName, "Renaming channel"], new Map()); - this.joinNewIRCClient(client, new Map()); - } - }) - }) - } - - joinNewIRCClient(client: Client, passedTags: Map) { - if (!client.user) - return; - this.ircUsers.set(client.user.nick, client.user); - if (client.enabledCaps.has('extended-join')) { - client.sendMessage(client.user.getMask(), "JOIN", [this.name, client.user.accountName, client.user.mxid], new Map([['account', client.user.realname]])); - } else { - client.sendMessage(client.user.getMask(), "JOIN", [this.name], new Map([['account', client.user.realname]])); - } - this.sendNames(client, passedTags); - this.sendTopic(client, passedTags); - } - - sendNames(client: Client, passedTags: Map) { - if (!client.user) - return; - let namesList: string[] = []; - for (const matrixUser of this.matrixUsers.values()) { - const opStatus = this.getNickPowerLevelMapping(matrixUser.nick); - namesList.push(`${opStatus}${matrixUser.nick}`); - } - let singleNamesList: string[] = [] - namesList.forEach((singleName, index) => { - if (index === 0 || index % 20 !== 0) { - singleNamesList.push(singleName); - } - else { - if (!client.user) - return; - client.sendMessage(client.server.name, "353", numerics["353"](client.user.nick, "=", this.name, singleNamesList), passedTags); - singleNamesList = []; - } - }) - if (singleNamesList.length !== 0) { - client.sendMessage(client.server.name, "353", numerics["353"](client.user.nick, "=", this.name, singleNamesList), passedTags); - } - client.sendMessage(client.server.name, "366", numerics["366"](client.user.nick, this.name), passedTags); - } - - sendMode(client: Client, passedTags: Map) { - if (!client.user) - return; - client.sendMessage(client.server.name, "324", numerics["324"](client.user.nick, this.name, `+n`), passedTags); - } - - sendTopic(client: Client, passedTags: Map) { - if (!client.user) - return; - const topicText = this.topic.get('text') || ''; - const topicSetter = this.topic.get('setter') || 'matrix'; - const topicTimestamp = this.topic.get('timestamp') || '0'; - if (topicText === '') { - client.sendMessage(client.server.name, '331', [client.user.nick, this.name, 'No topic is set'], passedTags); - return; - } - client.sendMessage(client.server.name, '332', [client.user.nick, this.name, topicText], passedTags); - client.sendMessage(client.server.name, '333', [client.user.nick, this.name, topicSetter, topicTimestamp], passedTags); - } - - sendWho(client: Client, passedTags: Map) { - if (!client.user) - return; - for (const matrixUser of this.matrixUsers.values()) { - const opStatus = this.getNickPowerLevelMapping(matrixUser.nick); - const userParams = [ - client.user.nick, - this.name, - matrixUser.ident, - matrixUser.hostname, - client.server.name, - matrixUser.nick, - `H${opStatus}`, - `0 ${matrixUser.realname}` - ] - client.sendMessage(client.server.name, '352', userParams, passedTags); - } - client.sendMessage(client.server.name, '315', [client.user.nick, this.name, "End of /WHO"], passedTags); - } - - handleModeChange(client: Client, message: IRCMessage) { - - } - - routeMatrixEvent(event: any) { - if (!event["type"] || !event["event_id"] || !event["origin_server_ts"]) - return; - - switch (event["type"]) { - case 'm.reaction': - this.handleMatrixReaction(event); - break; - case 'm.room.canonical_alias': - this.updateRoomName(event); - break; - case 'm.room.guest_access': - this.handleMatrixGuestAccess(event); - break; - case 'm.room.history_visibility': - this.handleMatrixHistoryVisibility(event); - break; - case 'm.room.join_rules': - this.handleMatrixJoinRule(event); - break; - case 'm.room.member': - this.handleMatrixMember(event); - break; - case 'm.room.message': { - if (this.eventIDsSeen.has(event["event_id"])) { - console.log(`duplicate event_id: ${event["event_id"]}`); - return; - } - this.eventIDsSeen.add(event["event_id"]); - this.handleMatrixMessage(event); - break; - } - case 'm.room.power_levels': - this.handleMatrixPL(event); - break; - case 'm.room.topic': - this.handleMatrixTopic(event); - 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': - break; - default: - console.log(event); - break; - } - } - - joinMatrixUser(matrixUser: MatrixUser, event: any) { - this.matrixUsers.set(matrixUser.nick, matrixUser); - const prefix = matrixUser.getMask(); - if (event) { - const tags = new Map([["account", matrixUser.accountName], ['time', new Date(event["origin_server_ts"]).toISOString()]]) - for (const user of this.ircUsers.values()) { - user.sendToAllWithCap('extended-join', prefix, "JOIN", [this.name, matrixUser.accountName, matrixUser.realname], tags); - user.sendToAllWithoutCap('extended-join', prefix, "JOIN", [this.name], tags); - } - } - } - - handleMatrixMember(event: any) { - const targetUser = this.server.getOrCreateMatrixUser(event["state_key"]); - const sourceUser = this.server.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.ircUsers.forEach((user) => { - user.getClients().forEach(c => { - if (c.enabledCaps.has('invite-notify')) { - if (c.enabledCaps.has('draft/extended-invite')) { - c.sendMessage(sourceUser.getMask(), 'INVITE', [targetUser.nick, this.name, reason], messageTags) - } else { - c.sendMessage(sourceUser.getMask(), 'INVITE', [targetUser.nick, this.name], messageTags) - } - } - }) - }); - } - else if (membershipStatus === "join") { - if (!this.matrixUsers.has(targetUser.nick)) - this.joinMatrixUser(targetUser, event); - } - else if (membershipStatus === "leave") { - if (!this.matrixUsers.has(targetUser.nick)) - return; - if (targetUser.mxid === sourceUser.mxid) { - const reason = content["reason"] || 'User left'; - this.ircUsers.forEach((user) => { - user.sendToAll(sourceUser.getMask(), 'PART', [this.name, reason], messageTags); - }); - } - else { - const reason = content["reason"] || 'User was kicked'; - this.ircUsers.forEach((user) => { - user.sendToAll(sourceUser.getMask(), 'KICK', [this.name, targetUser.nick, reason], messageTags); - }); - } - this.matrixUsers.delete(targetUser.nick) - } - else if (membershipStatus === "ban") { - if (!this.matrixUsers.has(targetUser.nick)) - return; - const reason = content["reason"] || 'User was banned'; - this.ircUsers.forEach((user) => { - user.sendToAll(sourceUser.getMask(), 'KICK', [this.name, targetUser.nick, reason], messageTags); - }); - this.matrixUsers.delete(targetUser.nick) - } - else { - console.log(`Got unknown m.room.member event: ${event}`); - } - } - - handleMatrixMessage(event: any) { - const thisMatrixUser = this.server.getOrCreateMatrixUser(event["sender"]); - if (!this.matrixUsers.has(thisMatrixUser.nick)) { - this.joinMatrixUser(thisMatrixUser, event); - } - 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', thisMatrixUser.accountName); - tags.set('time', new Date(event["origin_server_ts"]).toISOString()) - 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 = `https://matrix.org/_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.ircUsers.forEach((user) => { - user.sendToAll(thisMatrixUser.getMask(), ircCommand, [this.name, msg], tags) - }); - } - }); - } - - handleMatrixPL(event: any) { - const allUsers = event["content"]["users"]; - for (const [mxid, pl] of Object.entries(allUsers)) { - const thisMatrixUser = this.server.getOrCreateMatrixUser(event["sender"]); - this.matrixUsers.set(thisMatrixUser.nick, thisMatrixUser); - this.powerLevels.set(thisMatrixUser.nick, Number(pl)); - } - } - - handleMatrixReaction(event: any) { - const thisMatrixUser = this.server.getOrCreateMatrixUser(event["sender"]); - if (!this.matrixUsers.has(thisMatrixUser.nick)) { - this.joinMatrixUser(thisMatrixUser, event); - } - const tags: Map = new Map(); - tags.set('msgid', event["event_id"]); - tags.set('account', thisMatrixUser.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.ircUsers.forEach((user) => { - user.sendToAllWithCap("message-tags", thisMatrixUser.getMask(), "TAGMSG", [this.name], tags) - }); - } - - handleMatrixTopic(event: any) { - const topicText = event["content"]?.["topic"]; - if (!topicText) - return; - const topicSetter = this.server.getOrCreateMatrixUser(event["sender"]); - const topicTS: string = event["origin_server_ts"].toString(); - this.topic.set("text", topicText); - this.topic.set("timestamp", topicTS.substring(0,10)) - this.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.ircUsers.forEach((user) => { - user.sendToAll(topicSetter.getMask(), 'TOPIC', [this.name, topicText], messageTags); - }); - } - - handleMatrixJoinRule(event: any) { - const rule = event["content"]?.["join_rule"]; - if (!rule) { - console.log(`Warning: join rule not found in ${event}`); - return; - } - this.joinRules = rule; - } - - handleMatrixHistoryVisibility(event: any) { - const rule = event["content"]?.["history_visibility"]; - if (!rule) { - console.log(`Warning: history visibility not found in ${event}`); - return; - } - this.historyVisibility = rule; - } - - handleMatrixGuestAccess(event: any) { - const rule = event["content"]?.["guest_access"]; - if (!rule) { - console.log(`Warning: Guest access not found in ${event}`); - return; - } - this.guestAccess = rule; - } } \ No newline at end of file diff --git a/src/Client.ts b/src/Client.ts index 4072a97..de553dc 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { randomUUID } from 'crypto'; import { Socket } from 'net'; import { Channel } from './Channel.js'; import { IRCUser } from './IRCUser.js'; @@ -58,12 +59,7 @@ export class Client { getMatrixUserFromNick(targetNick: string, passedTags: Map = new Map()) { if (!this.user) return false; - const targetMxid = this.server.nickToMxid.get(targetNick); - if (!targetMxid) { - this.sendMessage(this.server.name, "401", [this.user.nick, targetNick, "No such nick"], passedTags); - return false; - } - const target = this.server.matrixUsers.get(targetMxid); + const target = this.user.nickToMatrixUser.get(targetNick); if (!target) { this.sendMessage(this.server.name, "401", [this.user.nick, targetNick, "No such nick"], passedTags); return false; @@ -73,7 +69,7 @@ export class Client { getChannel(channel: string, passedTags: Map = new Map()) { if (!this.user) return false; - const targetChannel = this.server.ircChannels.get(channel); + const targetChannel = this.user.channels.get(channel); if (!targetChannel) { this.sendMessage(this.server.name, "403", [this.user.nick, channel, "No such channel"], passedTags); return false; @@ -83,7 +79,7 @@ export class Client { checkIfInChannel(channel: Channel, passedTags: Map = new Map()) { if (!this.user) return false; - if (!channel.ircUsers.has(this.user.nick)) { + if (!this.user.channels.get(channel.name)) { this.sendMessage(this.server.name, "442", [this.user.nick, "You're not on that channel"], passedTags); return false; } @@ -99,142 +95,6 @@ export class Client { return true; } - routeMessage(data: string) { - const message = parseIRCMessage(data); - switch (message.command.toUpperCase()) { - case 'AUTHENTICATE': { - this.doAUTHENTICATE(message); - break; - } - case 'CAP': { - this.doCAP(message); - break; - } - case 'INVITE': { - if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 2)) - return; - const targetUser = this.getMatrixUserFromNick(message.params[0], message.tags); - const targetChannel = this.getChannel(message.params[1], message.tags); - if (!this.user || !targetUser || !targetChannel) return; - if (!this.checkIfInChannel(targetChannel, message.tags)) return; - if (targetChannel.matrixUsers.has(targetUser.nick)) { - this.sendMessage(this.server.name, "443", [this.user.nick, targetUser.nick, "is already on channel"], message.tags); - return; - } - const reason = (message.params.length === 3) ? message.params[2] : ""; - this.user.inviteMatrixUser(this, targetChannel, targetUser, reason, message.tags); - break; - } - case 'KICK': { - if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 2)) - return; - const targetChannel = this.getChannel(message.params[0], message.tags); - const targetUser = this.getMatrixUserFromNick(message.params[1], message.tags); - if (!this.user || !targetUser || !targetChannel) return; - if (!this.checkIfInChannel(targetChannel, message.tags)) return; - if (!targetChannel.matrixUsers.has(targetUser.nick)) { - this.sendMessage(this.server.name, "441", [this.user.nick, targetUser.nick, targetChannel.name, "They aren't on that channel"], message.tags); - return; - } - const reason = (message.params.length === 3) ? message.params[2] : ""; - this.user.kickMatrixUser(this, targetChannel, targetUser.mxid, reason, message.tags); - break; - } - case 'MODE': { - if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1) || !this.user) - return; - const targetChannel = this.server.ircChannels.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"], message.tags); - return; - } - this.sendMessage(this.server.name, "221", [this.user.nick, "+i"], message.tags); - return; - } - if (!this.checkIfInChannel(targetChannel, message.tags)) return; - if (message.params.length === 1) - targetChannel.sendMode(this, message.tags); - else - targetChannel.handleModeChange(this, message); - break; - } - case 'NAMES': { - if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1)) - return; - const targetChannel = this.getChannel(message.params[0], message.tags); - if (!this.user || !targetChannel) return; - if (!this.checkIfInChannel(targetChannel, message.tags)) return; - targetChannel.sendNames(this, message.tags); - break; - } - case 'NOTICE': { - if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 2)) - return; - const targetChannel = this.getChannel(message.params[0], message.tags); - if (!this.user || !targetChannel) return; - if (!this.checkIfInChannel(targetChannel, message.tags)) return; - this.user.sendMessageToMatrix(message, this); - break; - } - case 'PART': { - if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1)) - return; - const targetChannel = this.getChannel(message.params[0], message.tags); - if (!this.user || !targetChannel) return; - if (!this.checkIfInChannel(targetChannel, message.tags)) return; - const reason = (message.params.length === 2) ? message.params[1] : ""; - this.user.partMatrixRoom(this, targetChannel, reason, message.tags); - break; - } - case 'PING': { - this.sendMessage(this.server.name, "PONG", message.params, message.tags); - break; - } - case 'PRIVMSG': { - if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 2)) - return; - const targetChannel = this.getChannel(message.params[0], message.tags); - if (!this.user || !targetChannel) return; - if (!this.checkIfInChannel(targetChannel, message.tags)) return; - this.user.sendMessageToMatrix(message, this); - break; - } - case 'TAGMSG': { - if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1)) - return; - const targetChannel = this.getChannel(message.params[0], message.tags); - if (!this.user || !targetChannel) return; - if (!this.checkIfInChannel(targetChannel, message.tags)) return; - this.user.sendTagToMatrix(message, this); - break; - } - case 'TOPIC': { - if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1)) - return; - const targetChannel = this.getChannel(message.params[0], message.tags); - if (!this.user || !targetChannel) return; - if (!this.checkIfInChannel(targetChannel, message.tags)) return; - if (message.params.length === 1) { - targetChannel.sendTopic(this, message.tags); - break; - } - const topic = message.params[1]; - this.user.changeRoomTopic(this, targetChannel, topic, message.tags); - break; - } - case 'WHO': { - if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1)) - return; - const targetChannel = this.getChannel(message.params[0], message.tags); - if (!this.user || !targetChannel) return; - if (!this.checkIfInChannel(targetChannel, message.tags)) return; - targetChannel.sendWho(this, message.tags); - break; - } - } - } - getCapString(capVersion: string) { let capArray = []; for (const [key, value] of this.allCaps.entries()) { @@ -248,39 +108,48 @@ export class Client { return capArray.join(' '); } - doCAP(message: IRCMessage) { - switch (message.params[0]) { - case 'LS': { - if (message.params.length === 2) { - this.capVersion = message.params[1]; - } - this.sendMessage(this.server.name, "CAP", ["*", "LS", this.getCapString(this.capVersion)], message.tags); + routeMessage(data: string) { + const message = parseIRCMessage(data); + switch (message.command.toUpperCase()) { + case 'AUTHENTICATE': + this.doAUTHENTICATE(message); break; - } - case 'LIST': { - this.sendMessage(this.server.name, "CAP", ["*", "LIST", this.getCapString(this.capVersion)], message.tags); + case 'CAP': + this.doCAP(message); break; - } - case 'REQ': { - const capsToChange = (message.params[1].indexOf(' ') === -1) ? [message.params[1]] : message.params[1].split(' '); - const capsEnabled: string[] = []; - capsToChange.forEach(cap => { - if (this.allCaps.has(cap)) { - this.enabledCaps.set(cap, ''); - capsEnabled.push(cap); - } - }); - this.sendMessage(this.server.name, "CAP", ["*", "ACK", capsEnabled.join(' ')], message.tags); + case 'INVITE': + this.doINVITE(message); + break; + case 'KICK': + this.doKICK(message); + break; + case 'MODE': + this.doMODE(message); + break; + case 'NAMES': + this.doNAMES(message); + break; + case 'NOTICE': + this.doMSG(message); + break; + case 'PART': + this.doPART(message); + break; + case 'PING': + this.sendMessage(this.server.name, "PONG", message.params, message.tags); + break; + case 'PRIVMSG': + this.doMSG(message); + break; + case 'TAGMSG': + this.doTAGMSG(message); + break; + case 'TOPIC': + this.doTOPIC(message); + break; + case 'WHO': + this.doWHO(message); break; - } - case 'END': { - if (this.user !== null) { - this.doRegistration(message); - } - else { - this.closeConnectionWithError("You must use SASL to connect to this server"); - } - } } } @@ -331,11 +200,17 @@ export class Client { if (rooms['join']) { const joinedRooms: Set = new Set(); for (const roomId of Object.keys(rooms.join)) { - const targetChannel = this.server.getOrCreateIRCChannel(roomId); + const targetChannel = this.user.getOrCreateIRCChannel(roomId); joinedRooms.add(targetChannel); - rooms.join[roomId].state.events.forEach((nextEvent: any) => targetChannel.routeMatrixEvent(nextEvent)); + //@ts-ignore + rooms.join[roomId].state.events.forEach((nextEvent: any) => this.user.routeMatrixEvent(nextEvent, targetChannel)); } - joinedRooms.forEach(c => {if (this.user !== null) this.user.channels.add(c)}); + joinedRooms.forEach(c => { + if (this.user !== null) { + this.user.channels.set(c.name, c); + this.user.roomIdToChannel.set(c.roomId, c); + } + }); } if (this.user === null) return; @@ -351,6 +226,314 @@ export class Client { } } + doCAP(message: IRCMessage) { + switch (message.params[0]) { + case 'LS': { + if (message.params.length === 2) { + this.capVersion = message.params[1]; + } + this.sendMessage(this.server.name, "CAP", ["*", "LS", this.getCapString(this.capVersion)], message.tags); + break; + } + case 'LIST': { + this.sendMessage(this.server.name, "CAP", ["*", "LIST", this.getCapString(this.capVersion)], message.tags); + break; + } + case 'REQ': { + const capsToChange = (message.params[1].indexOf(' ') === -1) ? [message.params[1]] : message.params[1].split(' '); + const capsEnabled: string[] = []; + capsToChange.forEach(cap => { + if (this.allCaps.has(cap)) { + this.enabledCaps.set(cap, ''); + capsEnabled.push(cap); + } + }); + this.sendMessage(this.server.name, "CAP", ["*", "ACK", capsEnabled.join(' ')], message.tags); + break; + } + case 'END': { + if (this.user !== null) { + this.doRegistration(message); + } + else { + this.closeConnectionWithError("You must use SASL to connect to this server"); + } + } + } + } + + doINVITE(message: IRCMessage) { + if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 2)) + return; + const targetUser = this.getMatrixUserFromNick(message.params[0], message.tags); + const targetChannel = this.getChannel(message.params[1], message.tags); + if (!this.user || !targetUser || !targetChannel) return; + if (!this.checkIfInChannel(targetChannel, message.tags)) return; + if (targetChannel.matrixUsers.has(targetUser.nick)) { + this.sendMessage(this.server.name, "443", [this.user.nick, targetUser.nick, "is already on channel"], message.tags); + return; + } + const reason = (message.params.length === 3) ? message.params[2] : ""; + const data = { + "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) { + if (error.response) { + console.log(error.response.data); + } else if (error.request) { + console.log(error.request); + } else { + console.log('Error', error.message); + console.log(error.config); + } + }) + } + + doKICK(message: IRCMessage) { + if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 2)) + return; + const targetChannel = this.getChannel(message.params[0], message.tags); + const targetUser = this.getMatrixUserFromNick(message.params[1], message.tags); + if (!this.user || !targetUser || !targetChannel) return; + if (!this.checkIfInChannel(targetChannel, message.tags)) return; + if (!targetChannel.matrixUsers.has(targetUser.nick)) { + this.sendMessage(this.server.name, "441", [this.user.nick, targetUser.nick, targetChannel.name, "They aren't on that channel"], message.tags); + return; + } + const reason = (message.params.length === 3) ? message.params[2] : ""; + const data = { + "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) { + if (error.response) { + console.log(error.response.data); + } else if (error.request) { + console.log(error.request); + } else { + console.log('Error', error.message); + console.log(error.config); + } + }) + } + + doMODE(message: IRCMessage) { + if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1) || !this.user) + return; + const targetChannel = this.user.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"], message.tags); + return; + } + this.sendMessage(this.server.name, "221", [this.user.nick, "+i"], message.tags); + return; + } + if (!this.checkIfInChannel(targetChannel, message.tags)) return; + if (message.params.length === 1) { + this.sendMessage(this.server.name, "324", [this.user.nick, targetChannel.name, `+n`], message.tags); + return; + } + } + + doMSG(message: IRCMessage) { + if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 2)) + return; + const targetChannel = this.getChannel(message.params[0], message.tags); + if (!this.user || !targetChannel) return; + if (!this.checkIfInChannel(targetChannel, message.tags)) return; + let msgtype = 'm.text'; + let msgbody = message.params[1]; + if (message.command === 'NOTICE') + msgtype = 'm.notice'; + else if (message.params[1].startsWith('\x01')) { + msgtype = 'm.emote'; + msgbody = msgbody.replace(/\x01(ACTION\s)?/gi, ''); + } + const roomId = targetChannel.roomId; + const content = { + "body": msgbody, + "msgtype": msgtype, + "m.relates_to": {} + } + if (message.tags.has("+draft/reply")) { + content["m.relates_to"] = { + "m.in_reply_to": { + "event_id": message.tags.get("+draft/reply") + } + } + } + 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) { + if (error.response) { + console.log(error.response.data); + } else if (error.request) { + console.log(error.request); + } else { + console.log('Error', error.message); + console.log(error.config); + } + }); + } + + sendNAMES(targetChannel: Channel, passedTags: Map = new Map()) { + if (!this.user) return; + let namesList: string[] = []; + for (const matrixUser of targetChannel.matrixUsers.values()) { + const opStatus = targetChannel.getNickPowerLevelMapping(matrixUser.nick); + namesList.push(`${opStatus}${matrixUser.nick}`); + } + let singleNamesList: string[] = [] + namesList.forEach((singleName, index) => { + if (index === 0 || index % 20 !== 0) { + singleNamesList.push(singleName); + } + else { + //@ts-ignore + this.sendMessage(this.server.name, "353", numerics["353"](this.user.nick, "=", targetChannel.name, singleNamesList), passedTags); + singleNamesList = []; + } + }) + if (singleNamesList.length !== 0) { + this.sendMessage(this.server.name, "353", numerics["353"](this.user.nick, "=", targetChannel.name, singleNamesList), passedTags); + } + this.sendMessage(this.server.name, "366", numerics["366"](this.user.nick, targetChannel.name), passedTags); + } + + doNAMES(message: IRCMessage) { + if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1)) + return; + const targetChannel = this.getChannel(message.params[0], message.tags); + if (!this.user || !targetChannel) return; + if (!this.checkIfInChannel(targetChannel, message.tags)) return; + this.sendNAMES(targetChannel); + } + + doPART(message: IRCMessage) { + if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1)) + return; + const targetChannel = this.getChannel(message.params[0], message.tags); + if (!this.user || !targetChannel) return; + if (!this.checkIfInChannel(targetChannel, message.tags)) 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 => { + if (response.status === 200) { + //@ts-ignore + this.user.getClients().forEach(c => { + //@ts-ignore + c.sendMessage(this.user.getMask(), "PART", [targetChannel.name, reason], message.tags); + }) + //@ts-ignore + this.user.channels.delete(targetChannel.name); + //@ts-ignore + this.user.roomIdToChannel.delete(targetChannel.roomId); + } + else { + //@ts-ignore + this.sendMessage(this.server.name, "NOTICE", [this.user.nick, JSON.stringify(response.data)], message.tags); + } + }).catch(function (error) { + if (error.response) { + console.log(error.response.data); + } else if (error.request) { + console.log(error.request); + } else { + console.log('Error', error.message); + console.log(error.config); + } + }) + } + + doTAGMSG(message: IRCMessage) { + if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1)) + return; + const targetChannel = this.getChannel(message.params[0], message.tags); + if (!this.user || !targetChannel) return; + if (!this.checkIfInChannel(targetChannel, message.tags)) return; + if (message.tags.has("+draft/react") && message.tags.has("+draft/reply")) { + const content = { + "m.relates_to": { + "event_id": message.tags.get("+draft/reply"), + "key": message.tags.get("+draft/react"), + "rel_type": "m.annotation" + } + } + 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) { + if (error.response) { + console.log(error.response.data); + } else if (error.request) { + console.log(error.request); + } else { + console.log('Error', error.message); + console.log(error.config); + } + }); + } + } + + sendTOPIC(targetChannel: Channel, passedTags: Map = new Map()) { + if (!this.user) return; + const topicText = targetChannel.topic.get('text') || ''; + const topicSetter = targetChannel.topic.get('setter') || 'matrix'; + const topicTimestamp = targetChannel.topic.get('timestamp') || '0'; + if (topicText === '') { + this.sendMessage(this.server.name, '331', [this.user.nick, targetChannel.name, 'No topic is set'], passedTags); + return; + } + this.sendMessage(this.server.name, '332', [this.user.nick, targetChannel.name, topicText], passedTags); + this.sendMessage(this.server.name, '333', [this.user.nick, targetChannel.name, topicSetter, topicTimestamp], passedTags); + } + + doTOPIC(message: IRCMessage) { + if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1)) + return; + const targetChannel = this.getChannel(message.params[0], message.tags); + if (!this.user || !targetChannel) return; + if (!this.checkIfInChannel(targetChannel, message.tags)) return; + if (message.params.length === 1) { + this.sendTOPIC(targetChannel, message.tags); + 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) { + if (error.response) { + console.log(error.response.data); + } else if (error.request) { + console.log(error.request); + } else { + console.log('Error', error.message); + console.log(error.config); + } + }) + } + + doWHO(message: IRCMessage) { + if (!this.checkIfRegistered(message.tags) || !this.checkMinParams(message, 1)) + return; + const targetChannel = this.getChannel(message.params[0], message.tags); + if (!this.user || !targetChannel) return; + if (!this.checkIfInChannel(targetChannel, message.tags)) return; + for (const matrixUser of targetChannel.matrixUsers.values()) { + const opStatus = targetChannel.getNickPowerLevelMapping(matrixUser.nick); + const userParams = [ + this.user.nick, + targetChannel.name, + matrixUser.ident, + matrixUser.hostname, + this.server.name, + matrixUser.nick, + `H${opStatus}`, + `0 ${matrixUser.realname}` + ] + this.sendMessage(this.server.name, '352', userParams, message.tags); + } + this.sendMessage(this.server.name, '315', [this.user.nick, targetChannel.name, "End of /WHO"], message.tags); + } + doRegistration(message: IRCMessage) { if (this.user === null) { this.closeConnectionWithError("You must use SASL to connect to this server"); diff --git a/src/IRCUser.ts b/src/IRCUser.ts index 7f9e011..3b4e5c8 100644 --- a/src/IRCUser.ts +++ b/src/IRCUser.ts @@ -1,42 +1,80 @@ import axios from "axios"; -import { randomUUID } from "crypto"; import { Channel } from "./Channel.js"; import { Client } from "./Client.js"; import { MatrixUser } from "./MatrixUser.js"; -import { IRCMessage } from "./Message.js"; import { Server } from "./Server.js"; export class IRCUser { private clients: Set - public channels: Set + public channels: Map + public roomIdToChannel: Map + public matrixUsers: Map + public nickToMatrixUser: Map public nick: string private ident: string private hostname: string public accountName: string public realname: string - private txnIdStore: Map + public ourMatrixUser: MatrixUser + public txnIdStore: Map public nextBatch: string - private initialSync: boolean private isSyncing: boolean private currentSyncTime: number private syncIntervalID: NodeJS.Timeout; - constructor(public mxid: string, private accessToken: string, public homeserver: string, private server: Server) { + constructor(public mxid: string, public accessToken: string, public homeserver: string, private server: Server) { this.clients = new Set(); - this.channels = new Set(); + this.channels = new Map(); + this.roomIdToChannel = new Map(); + this.matrixUsers = new Map(); + this.nickToMatrixUser = new Map(); const mxidSplit = mxid.split(':') - this.nick = mxidSplit[0].substr(1); + 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); } + getOrCreateMatrixUser(mxid: string): MatrixUser { + let maybeMatrixUser = this.matrixUsers.get(mxid); + if (maybeMatrixUser) { + return maybeMatrixUser; + } + let potentialNick = mxid.split(":")[0].substring(1); + if (!this.nickToMatrixUser.has(potentialNick)) { + const newMatrixUser = new MatrixUser(mxid, potentialNick); + this.matrixUsers.set(mxid, newMatrixUser); + this.nickToMatrixUser.set(potentialNick, newMatrixUser); + return newMatrixUser; + } + const homeserverArray = mxid.split(":")[1].split('.'); + const baseDomainNum = homeserverArray.length - 2; + potentialNick = `${potentialNick}-${homeserverArray[baseDomainNum]}`; + 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.roomIdToChannel.set(roomId, newChannel); + this.channels.set(roomId, newChannel); + return newChannel; + } + isSynced() { return this.nextBatch !== ""; } @@ -57,11 +95,21 @@ export class IRCUser { return this.clients; } + joinNewIRCClient(client: Client, targetChannel: Channel, passedTags: Map) { + 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, passedTags); + client.sendTOPIC(targetChannel, passedTags); + } + addClient(client: Client, passedTags: Map) { this.clients.add(client); if (this.nextBatch !== "") { for (const channel of this.channels.values()) { - channel.joinNewIRCClient(client, passedTags); + this.joinNewIRCClient(client, channel, passedTags); } } } @@ -85,165 +133,282 @@ export class IRCUser { const rooms = data.rooms; if (rooms && rooms['join']) { for (const roomId of Object.keys(rooms.join)) { - const targetChannel = this.server.getOrCreateIRCChannel(roomId); + const targetChannel = this.getOrCreateIRCChannel(roomId); rooms.join[roomId].timeline.events.forEach((nextEvent: any) => { - targetChannel.routeMatrixEvent(nextEvent) + this.routeMatrixEvent(nextEvent, targetChannel); }); } } this.isSyncing = false; }).catch((error) => { console.log(error); - if (error.response) { - console.log(`Response error: ${error.response.status}`); - } else if (error.request) { - console.log(error.request); - console.log(error.config); - } else { - console.log('Error', error.message); - console.log(error.config); - } this.isSyncing = false; }); } - inviteMatrixUser(client: Client, channel: Channel, target: MatrixUser, reason: string, passedTags: Map = new Map()) { - const data = { - "reason": reason, - "user_id": target.mxid - } - axios.post(`https://${this.homeserver}/_matrix/client/v3/rooms/${channel.roomId}/invite?access_token=${this.accessToken}`, data).catch(function (error) { - if (error.response) { - console.log(error.response.data); - } else if (error.request) { - console.log(error.request); - } else { - console.log('Error', error.message); - console.log(error.config); + 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.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': + break; + default: + console.log(nextEvent); + break; + } } - kickMatrixUser(client: Client, channel: Channel, target: string, reason: string, passedTags: Map = new Map()) { - const data = { - "reason": reason, - "user_id": target + 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); } - axios.post(`https://${this.homeserver}/_matrix/client/v3/rooms/${channel.roomId}/kick?access_token=${this.accessToken}`, data).catch(function (error) { - if (error.response) { - console.log(error.response.data); - } else if (error.request) { - console.log(error.request); - } else { - console.log('Error', error.message); - console.log(error.config); - } - }) + 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); + this.sendToAllWithCap("message-tags", sourceUser.getMask(), "TAGMSG", [targetChannel.name], tags) } - partMatrixRoom(client: Client, channel: Channel, reason: string, passedTags: Map = new Map()) { - axios.post(`https://${this.homeserver}/_matrix/client/v3/rooms/${channel.roomId}/leave?access_token=${this.accessToken}`, {"reason": reason}).then(response => { - if (response.status === 200) { - this.clients.forEach(c => { - c.sendMessage(this.getMask(), "PART", [channel.name, reason], passedTags); - }) - this.channels.delete(channel); + updateRoomName(newNameEvent: any, targetChannel: Channel) { + const newName: string = newNameEvent["content"]["alias"]; + if (!newName || newName === targetChannel.name) + return; + 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.server.name, "NOTICE", [this.nick, JSON.stringify(response.data)], passedTags); - } - }).catch(function (error) { - if (error.response) { - console.log(error.response.data); - } else if (error.request) { - console.log(error.request); - } else { - console.log('Error', error.message); - console.log(error.config); + client.sendMessage(this.server.name, "PART", [oldName, "Renaming channel"], new Map()); + this.joinNewIRCClient(client, targetChannel, new Map()); } }) } - changeRoomTopic(client: Client, channel: Channel, topic: string, passedTags: Map = new Map()) { - axios.put(`https://${this.homeserver}/_matrix/client/v3/rooms/${channel.roomId}/state/m.room.topic?access_token=${this.accessToken}`, {"topic": topic}).catch(function (error) { - if (error.response) { - console.log(error.response.data); - } else if (error.request) { - console.log(error.request); - } else { - console.log('Error', error.message); - console.log(error.config); - } - }) - } - - sendMessageToMatrix(message: IRCMessage, client: Client) { - const channel = this.server.ircChannels.get(message.params[0]); - if (!channel) { + handleMatrixGuestAccess(event: any, targetChannel: Channel) { + const rule = event["content"]?.["guest_access"]; + if (!rule) { + console.log(`Warning: Guest access not found in ${event}`); return; } - let msgtype = 'm.text'; - let msgbody = message.params[1]; - if (message.command === 'NOTICE') - msgtype = 'm.notice'; - else if (message.params[1].startsWith('\x01')) { - msgtype = 'm.emote'; - msgbody = msgbody.replace(/\x01(ACTION\s)?/gi, ''); + 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; } - const roomId = channel.roomId; - const content = { - "body": msgbody, - "msgtype": msgtype, - "m.relates_to": {} + 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; } - if (message.tags.has("+draft/reply")) { - content["m.relates_to"] = { - "m.in_reply_to": { - "event_id": message.tags.get("+draft/reply") + 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); } } - const newTxnid = randomUUID(); - this.txnIdStore.set(newTxnid, client); - axios.put(`https://${this.homeserver}/_matrix/client/v3/rooms/${channel.roomId}/send/m.room.message/${newTxnid}?access_token=${this.accessToken}`, content).catch(function (error) { - if (error.response) { - console.log(error.response.data); - } else if (error.request) { - console.log(error.request); - } else { - console.log('Error', error.message); - console.log(error.config); + 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 = 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:\/\/(?[^\/]+)\/(?.+)/) + 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) + } + + } + client.sendMessage(sourceUser.getMask(), ircCommand, [targetChannel.name, msg], tags) + }); } }); } - sendTagToMatrix(message: IRCMessage, client: Client) { - const channel = this.server.ircChannels.get(message.params[0]); - if (!channel) { + handleMatrixPL(event: any, targetChannel: Channel) { + const allUsers = event["content"]["users"]; + for (const [mxid, pl] of Object.entries(allUsers)) { + const thisMatrixUser = this.getOrCreateMatrixUser(event["sender"]); + targetChannel.matrixUsers.set(thisMatrixUser.nick, thisMatrixUser); + targetChannel.powerLevels.set(thisMatrixUser.nick, Number(pl)); + } + } + + handleMatrixTopic(event: any, targetChannel: Channel) { + const topicText = event["content"]?.["topic"]; + if (!topicText) return; - } - console.log(message.tags) - if (message.tags.has("+draft/react") && message.tags.has("+draft/reply")) { - const content = { - "m.relates_to": { - "event_id": message.tags.get("+draft/reply"), - "key": message.tags.get("+draft/react"), - "rel_type": "m.annotation" - } - } - const newTxnid = randomUUID(); - this.txnIdStore.set(newTxnid, client); - axios.put(`https://${this.homeserver}/_matrix/client/v3/rooms/${channel.roomId}/send/m.reaction/${newTxnid}?access_token=${this.accessToken}`, content).catch(function (error) { - if (error.response) { - console.log(error.response.data); - } else if (error.request) { - console.log(error.request); - } else { - console.log('Error', error.message); - console.log(error.config); - } - }); - } + 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 = new Map(), skipClient: Client|null = null) { diff --git a/src/Server.ts b/src/Server.ts index 3f4e455..7dc8f65 100644 --- a/src/Server.ts +++ b/src/Server.ts @@ -1,25 +1,11 @@ -import { Channel } from "./Channel.js" import { IRCUser } from "./IRCUser.js" -import { MatrixUser } from "./MatrixUser.js" export class Server { public name: string - // - public matrixRooms: Map - // - public ircChannels: Map - // - public matrixUsers: Map - // public ircUsers: Map - public nickToMxid: Map constructor(public config: any) { this.name = this.config.serverName; - this.matrixRooms = new Map(); - this.ircChannels = new Map(); - this.matrixUsers = new Map(); this.ircUsers = new Map(); - this.nickToMxid = new Map(); } getOrCreateIRCUser(mxid: string, accessToken: string, homeserver: string): IRCUser { @@ -29,36 +15,4 @@ export class Server { } return new IRCUser(mxid, accessToken, homeserver, this); } - - getOrCreateIRCChannel(roomId: string): Channel { - const maybeChannel = this.matrixRooms.get(roomId); - if (maybeChannel) - return maybeChannel; - - const newChannel = new Channel(roomId, this); - this.matrixRooms.set(roomId, newChannel); - this.ircChannels.set(roomId, newChannel); - return newChannel; - } - - getOrCreateMatrixUser(mxid: string): MatrixUser { - let maybeMatrixUser = this.matrixUsers.get(mxid); - if (maybeMatrixUser) { - return maybeMatrixUser; - } - let potentialNick = mxid.split(":")[0].substr(1); - if (!this.nickToMxid.has(potentialNick)) { - const newMatrixUser = new MatrixUser(mxid, potentialNick); - this.matrixUsers.set(mxid, newMatrixUser); - this.nickToMxid.set(potentialNick, mxid); - return newMatrixUser; - } - const homeserverArray = mxid.split(":")[1].split('.'); - const baseDomainNum = homeserverArray.length - 2; - potentialNick = `${potentialNick}-${homeserverArray[baseDomainNum]}`; - const newMatrixUser = new MatrixUser(mxid, potentialNick); - this.matrixUsers.set(mxid, newMatrixUser); - this.nickToMxid.set(potentialNick, mxid); - return newMatrixUser; - } } \ No newline at end of file