mirror of
https://git.sr.ht/~emerson/reflectionircd
synced 2025-08-05 16:59:10 +00:00
lots of changes
This commit is contained in:
parent
4b8ee81b23
commit
209fe0451a
7 changed files with 281 additions and 29 deletions
32
README.md
32
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
|
||||
|
||||
<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
|
||||
|
|
46
docs/specs/delete-message.md
Normal file
46
docs/specs/delete-message.md
Normal 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
|
||||
|
51
docs/specs/extended-invite.md
Normal file
51
docs/specs/extended-invite.md
Normal 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!
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
Loading…
Add table
Reference in a new issue