lots of changes

This commit is contained in:
emerson 2022-01-24 10:23:06 -05:00
parent 4b8ee81b23
commit 209fe0451a
7 changed files with 281 additions and 29 deletions

View file

@ -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
<small>(IRCv3)</small> 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 <small>(IRCv3)</small> | 🟨 | ❌ | Only when the canonical alias changes |
| Message replies <small>(IRCv3)</small> | ✅ | ❌ ||
| Message reactions <small>(IRCv3)</small> | ❌ | ❌ ||
| Chat history <small>(IRCv3)</small> | ❌ | ❌ ||
| Message replies <small>(IRCv3)</small> | ✅ | ✅ | Clients without support will still receive the reply message, but it won't be marked as a reply |
| Message reactions <small>(IRCv3)</small> | 🟨 | 🟨 | Can't undo reactions yet |
| Extended invites <small>(IRCv3)</small> | ✅ | ✅ ||
| Chat history <small>(IRCv3)</small> | ⬜ | ❌ ||
| Multiline messages <small>(IRCv3)</small> | ❌ | ❌ ||
| Global display names <small>(IRCv3)</small> | ❌ | ❌ ||
| Per-room display names <small>(IRCv3)</small> | ❌ | ❌ ||
| Message editing/deletion <small>(IRCv3)</small> | ❌ | ❌ ||
| Message editing <small>(IRCv3)</small> | ❌ | ❌ ||
| Message deletion <small>(IRCv3)</small> | ❌ | ❌ ||
## 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

View file

@ -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

View file

@ -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!

View file

@ -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<string, string> = 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)

View file

@ -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))

View file

@ -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<string, string> = 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<string, string> = 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<string, string> = 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<string, string> = 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<string, string> = new Map(), skipClient: Client|null = null) {
this.clients.forEach(client => {
if (client !== skipClient) {

View file

@ -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);
}