diff --git a/README.md b/README.md index a91010c..b0a61e1 100644 --- a/README.md +++ b/README.md @@ -14,32 +14,38 @@ That said, it is usable for basic chatting. ## Feature support -✅ - Fully supported -🟨 - Partially supported, see notes +✅ - Implemented +🟨 - Partially implemented, see notes ❌ - Not implemented yet +⬜ - Not applicable (IRCv3) denotes IRC features that might not be available in all clients | Name | M->I | I->M | Notes | | ---- | :--: | :--: | ----- | | text, notice, emote messages | ✅ | ✅ || -| image, file, audio, video messages | 🟨 | ❌ | Show up as links on IRC | +| image, file, audio, video messages | 🟨 | ⬜ | Show up as links on IRC | | Channel joins | ✅ | ❌ || -| Channel parts | ✅ | ❌ || -| Channel kicks | ✅ | ❌ || -| Channel bans | 🟨 | ❌ | Bans show up on IRC as kicks | -| Channel invites | ✅ | ❌ || -| Channel topics | ✅ | ❌ || +| Channel parts | ✅ | ✅ || +| Channel kicks | ✅ | ✅ || +| Channel bans | 🟨 | ❌ | Single-user bans show up on IRC as kicks, there's no banlist yet | +| Channel invites | ✅ | ✅ || +| Channel topics | ✅ | ✅ || | Channel powers | ❌ | ❌ || -| Encrypted rooms | ❌ | ❌ || +| Channel lists/searching | ⬜ | ❌ || +| Encrypted rooms | ❌ | ⬜ || +| Rich text | ❌ | ❌ || +| Presence | ❌ | ❌ || | Channel renaming (IRCv3) | 🟨 | ❌ | Only when the canonical alias changes | -| Message replies (IRCv3) | ✅ | ❌ || -| Message reactions (IRCv3) | ❌ | ❌ || -| Chat history (IRCv3) | ❌ | ❌ || +| Message replies (IRCv3) | ✅ | ✅ | Clients without support will still receive the reply message, but it won't be marked as a reply | +| Message reactions (IRCv3) | 🟨 | 🟨 | Can't undo reactions yet | +| Extended invites (IRCv3) | ✅ | ✅ || +| Chat history (IRCv3) | ⬜ | ❌ || | Multiline messages (IRCv3) | ❌ | ❌ || | Global display names (IRCv3) | ❌ | ❌ || | Per-room display names (IRCv3) | ❌ | ❌ || -| Message editing/deletion (IRCv3) | ❌ | ❌ || +| Message editing (IRCv3) | ❌ | ❌ || +| Message deletion (IRCv3) | ❌ | ❌ || ## Running Copy `config.example.json` to `config.json`, edit the values if needed, then `npm run build` and then `node reflection.js` to start it diff --git a/docs/specs/delete-message.md b/docs/specs/delete-message.md new file mode 100644 index 0000000..3e390ae --- /dev/null +++ b/docs/specs/delete-message.md @@ -0,0 +1,46 @@ +--- +title: Message Deletion +layout: spec +work-in-progress: true +copyrights: + - + name: "Emerson Veenstra" + email: "ircv3@emersonveenstra.net" + period: "2022" +--- + +## Notes for implementing work-in-progress version + +This is a work-in-progress specification. + +Software implementing this work-in-progress specification MUST NOT use the +unprefixed `delete-message` capability name. Instead, implementations SHOULD +use the `draft/delete-message` capability name to be interoperable with other +software implementing a compatible work-in-progress version. + +The final version of the specification will use an unprefixed capability name. + + +## Introduction + +This specification describes a standardized way to signal to clients that a previously +sent message should no longer be displayed. + +## Implementation + +Servers and clients implementing this spec MUST also implement the `message-tags` capability. + +To request message deletion, clients send a `DELETEMSG` to the channel or user of the original message. +This `DELETEMSG` MUST have the tag `draft/delete-message` with a required value of the `msgid` of the +message to delete. It MAY have an optional second parameter to specify a reason for deletion. + +Clients who receive a `DELETEMSG` with the `draft/delete-message` tag MUST remove all displayed content from +the specified `msgid`. If the original message is ephemeral, clients SHOULD remove it entirely; +otherwise they SHOULD replace it with the deletion reason, or a generic substitute. + +Servers MUST ensure that the user requesting deletion has sufficient privileges to delete the specified +message. Servers MUST remove the content of the original message from all persistent history stores, and +MAY replace the content with a generic deletion message if needed. + +## Examples + diff --git a/docs/specs/extended-invite.md b/docs/specs/extended-invite.md new file mode 100644 index 0000000..1f4090f --- /dev/null +++ b/docs/specs/extended-invite.md @@ -0,0 +1,51 @@ +--- +title: Extended Invites +layout: spec +work-in-progress: true +copyrights: + - + name: "Emerson Veenstra" + email: "ircv3@emersonveenstra.net" + period: "2022" +--- + +## Notes for implementing work-in-progress version + +This is a work-in-progress specification. + +Software implementing this work-in-progress specification MUST NOT use the +unprefixed `extended-invite` capability name. Instead, implementations SHOULD +use the `draft/extended-invite` capability name to be interoperable with other +software implementing a compatible work-in-progress version. + +The final version of the specification will use an unprefixed capability name. + + +## Introduction + +This specification extends the `INVITE` command to allow for an optional reason. +The reason can be used to give more context around why the invite was sent. + +## Implementation + +Servers implementing this spec MUST also implement the `invite-notify` capability. Clients SHOULD +negotiate the `invite-notify` capability when negotiating `extended-invite`. + +## Capabilities + +Clients that have negotiated the `extended-invite` capability MAY add a final parameter on +an `/INVITE` command. This parameter is to give the target user context for the invite. This +parameter MUST NOT cause the message to exceed the maximum allowable line length of the +server. + +Servers that implement `extended-invite` MUST accept `INVITE` commands with three parameters. +If the user receiving the invite has negotiated `extended-invite`, the server sends the +final parameter to that user. Additionally, the final parameter is added to the `INVITE` command +sent to any users that have negotiated both the `extended-invite` and `invite-notify` capabilities. + +## Examples + +For example: + + C: INVITE emerson #project-test :We're testing out our project in here! + S: n!u@h INVITE emerson #project-test :We're testing out our project in here! \ No newline at end of file diff --git a/src/Channel.ts b/src/Channel.ts index a9f0af0..41ef607 100644 --- a/src/Channel.ts +++ b/src/Channel.ts @@ -145,6 +145,9 @@ export class Channel { return; switch (event["type"]) { + case 'm.reaction': + this.handleMatrixReaction(event); + break; case 'm.room.canonical_alias': this.updateRoomName(event); break; @@ -192,7 +195,7 @@ export class Channel { case 'org.matrix.room.preview_urls': break; default: - console.log(event["type"]); + console.log(event); break; } } @@ -222,8 +225,17 @@ export class Channel { 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.sendToAllWithCap('invite-notify', sourceUser.getMask(), 'INVITE', [targetUser.nick, this.name], messageTags); + 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") { @@ -311,6 +323,28 @@ export class Channel { } } + 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) diff --git a/src/Client.ts b/src/Client.ts index 03e156b..1dfc479 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -60,6 +60,25 @@ export class Client { this.doCAP(message); break; } + case 'INVITE': { + const targetUser = this.server.getOrCreateMatrixUser(message.params[0]) + const targetChannel = this.server.ircChannels.get(message.params[1]); + const reason = (message.params.length === 3) ? message.params[2] : ""; + if (this.user && targetChannel && targetChannel.ircUsers.get(this.user.nick)) + this.user.inviteMatrixUser(this, targetChannel, targetUser, reason, message.tags); + break; + } + case 'KICK': { + const targetChannel = this.server.ircChannels.get(message.params[0]); + const targetMxid = this.server.nickToMxid.get(message.params[1]); + if (!targetMxid) + return; + console.log(targetMxid); + const reason = (message.params.length === 3) ? message.params[2] : ""; + if (this.user && targetChannel && targetChannel.ircUsers.get(this.user.nick)) + this.user.kickMatrixUser(this, targetChannel, targetMxid, reason, message.tags); + break; + } case 'MODE': { const targetChannel = this.server.ircChannels.get(message.params[0]); if (this.user && targetChannel && targetChannel.ircUsers.get(this.user.nick)) @@ -78,6 +97,13 @@ export class Client { } break; } + case 'PART': { + const targetChannel = this.server.ircChannels.get(message.params[0]); + const reason = (message.params.length === 2) ? message.params[1] : ""; + if (this.user && targetChannel && targetChannel.ircUsers.get(this.user.nick)) + this.user.partMatrixRoom(this, targetChannel, reason, message.tags); + break; + } case 'PING': { this.sendMessage(this.server.name, "PONG", message.params, message.tags); break; @@ -88,6 +114,24 @@ export class Client { } break; } + case 'TAGMSG': { + if (this.user) { + this.user.sendTagToMatrix(message, this); + } + break; + } + case 'TOPIC': { + const targetChannel = this.server.ircChannels.get(message.params[0]); + if (!this.user || !targetChannel || !targetChannel.ircUsers.get(this.user.nick)) + break; + 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': { const targetChannel = this.server.ircChannels.get(message.params[0]); if (this.user && targetChannel && targetChannel.ircUsers.get(this.user.nick)) diff --git a/src/IRCUser.ts b/src/IRCUser.ts index 455f44c..2fd075e 100644 --- a/src/IRCUser.ts +++ b/src/IRCUser.ts @@ -2,6 +2,7 @@ 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"; @@ -29,7 +30,7 @@ export class IRCUser { this.txnIdStore = new Map(); this.nextBatch = ""; this.initialSync = false; - this.syncIntervalID = setInterval(this.doSync.bind(this), 15000); + this.syncIntervalID = setInterval(this.doSync.bind(this), 2000); } isSynced() { @@ -78,6 +79,49 @@ export class IRCUser { }) } + 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).then(response => { + if (response.status !== 200) + client.sendMessage(this.server.name, "NOTICE", [this.nick, JSON.stringify(response.data)], passedTags); + }) + } + + kickMatrixUser(client: Client, channel: Channel, target: string, reason: string, passedTags: Map = new Map()) { + const data = { + "reason": reason, + "user_id": target + } + axios.post(`https://${this.homeserver}/_matrix/client/v3/rooms/${channel.roomId}/kick?access_token=${this.accessToken}`, data).then(response => { + if (response.status !== 200) + client.sendMessage(this.server.name, "NOTICE", [this.nick, JSON.stringify(response.data)], passedTags); + }) + } + + 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); + } + else { + client.sendMessage(this.server.name, "NOTICE", [this.nick, JSON.stringify(response.data)], passedTags); + } + }) + } + + 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}).then(response => { + if (response.status !== 200) + client.sendMessage(this.server.name, "NOTICE", [this.nick, JSON.stringify(response.data)], passedTags); + }) + } + sendMessageToMatrix(message: IRCMessage, client: Client) { const channel = this.server.ircChannels.get(message.params[0]); if (!channel) { @@ -95,12 +139,40 @@ export class IRCUser { 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.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); } + sendTagToMatrix(message: IRCMessage, client: Client) { + const channel = this.server.ircChannels.get(message.params[0]); + if (!channel) { + 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); + } + } + sendToAll(prefix: string, command: string, params: string[], tags: Map = new Map(), skipClient: Client|null = null) { this.clients.forEach(client => { if (client !== skipClient) { diff --git a/src/Message.ts b/src/Message.ts index ef76f5d..5e185f4 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -79,24 +79,23 @@ export function parseIRCMessage(rawLine: string) { let command = ''; let params: string[] = []; if (rawLine.startsWith('@')) { - const tags = restOfMessage.substr(0, restOfMessage.indexOf(' ')); - restOfMessage = restOfMessage.substr(restOfMessage.indexOf(' ')+1); - - for (const tag in tags.split(';')) { + const tags = restOfMessage.substring(1, restOfMessage.indexOf(' ')); + restOfMessage = restOfMessage.substring(restOfMessage.indexOf(' ')+1); + for (const tag of tags.split(';')) { const valueSplit = tag.indexOf('='); if (valueSplit === -1 && addToTags(tag)) { parsedTags.set(tag, ''); continue; } - const key = tag.substr(0, valueSplit); - const value = tag.substr(valueSplit); + const key = tag.substring(0, valueSplit); + const value = tag.substring(valueSplit+1); if (addToTags(key)) parsedTags.set(key, decodeTag(value)); } } if (restOfMessage.startsWith(':')) { - prefix = restOfMessage.substr(0, restOfMessage.indexOf(' ')); - restOfMessage = restOfMessage.substr(restOfMessage.indexOf(' ')+1); + prefix = restOfMessage.substring(0, restOfMessage.indexOf(' ')); + restOfMessage = restOfMessage.substring(restOfMessage.indexOf(' ')+1); } if (restOfMessage.indexOf(' ') === -1) { @@ -104,19 +103,19 @@ export function parseIRCMessage(rawLine: string) { return new IRCMessage(parsedTags, prefix, command, params); } - command = restOfMessage.substr(0, restOfMessage.indexOf(' ')); - restOfMessage = restOfMessage.substr(restOfMessage.indexOf(' ') + 1); + command = restOfMessage.substring(0, restOfMessage.indexOf(' ')); + restOfMessage = restOfMessage.substring(restOfMessage.indexOf(' ') + 1); let lastParam = ''; if (restOfMessage.indexOf(' :') !== -1) { - lastParam = restOfMessage.substr(restOfMessage.indexOf(' :') + 2); - restOfMessage = restOfMessage.substr(0, restOfMessage.indexOf(' :')); + lastParam = restOfMessage.substring(restOfMessage.indexOf(' :') + 2); + restOfMessage = restOfMessage.substring(0, restOfMessage.indexOf(' :')); } params = restOfMessage.split(' '); if (lastParam !== '') { params.push(lastParam); } - //console.log(parsedTags, prefix, command, params); + console.log(parsedTags, prefix, command, params); return new IRCMessage(parsedTags, prefix, command, params); } \ No newline at end of file