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