diff --git a/README.md b/README.md index 5179f01af39f1e9fb7db84b3d0da24763082221f..089ce78951e32646d572ec25f0689a66d503194f 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ In a vague order of what is coming up next - [x] Audio/Video content - [ ] Typing notifs (**Not supported, requires syncing**) - [x] User Profiles + - [ ] Reactions - Discord -> Matrix - [x] Text content - [x] Image content @@ -152,6 +153,7 @@ In a vague order of what is coming up next - [x] User Profiles - [x] Presence - [x] Per-guild display names. + - [x] Reactions - [x] Group messages - [ ] Third Party Lookup - [x] Rooms diff --git a/changelog.d/862.feature b/changelog.d/862.feature new file mode 100644 index 0000000000000000000000000000000000000000..a693baafcfc131dce751aa5750e545c27ccc9aee --- /dev/null +++ b/changelog.d/862.feature @@ -0,0 +1 @@ +Adds one-way reaction support from Discord -> Matrix. Thanks to @SethFalco! diff --git a/package.json b/package.json index d382f29c686aebd68b976ac5c72335135dcd7c12..a9b864d9b99f1dd0911c0e5f0d8477501db29a48 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ }, "homepage": "https://github.com/Half-Shot/matrix-appservice-discord#readme", "dependencies": { - "@mx-puppet/matrix-discord-parser": "0.1.10", + "@deurstann/matrix-discord-parser": "1.10.7", "better-discord.js": "github:matrix-org/better-discord.js#5024781db755259e88abe915630b7d5b3ba5f48f", "better-sqlite3": "^7.1.0", "command-line-args": "^5.1.1", diff --git a/src/bot.ts b/src/bot.ts index 0ca0b768ab28c73af095abc216b5da394d786c9e..0a198470e591325f55891562160374c56d1504f4 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -20,7 +20,7 @@ import { DiscordStore } from "./store"; import { DbEmoji } from "./db/dbdataemoji"; import { DbEvent } from "./db/dbdataevent"; import { DiscordMessageProcessor } from "./discordmessageprocessor"; -import { IDiscordMessageParserResult } from "@mx-puppet/matrix-discord-parser"; +import { IDiscordMessageParserResult } from "@deurstann/matrix-discord-parser"; import { MatrixEventProcessor, MatrixEventProcessorOpts, IMatrixEventProcessorResult } from "./matrixeventprocessor"; import { PresenceHandler } from "./presencehandler"; import { Provisioner } from "./provisioner"; @@ -263,6 +263,21 @@ export class DiscordBot { await this.channelSync.OnGuildDelete(guild); } catch (err) { log.error("Exception thrown while handling \"guildDelete\" event", err); } }); + client.on("messageReactionAdd", async (reaction, user) => { + try { + await this.OnMessageReactionAdd(reaction, user); + } catch (err) { log.error("Exception thrown while handling \"messageReactionAdd\" event", err); } + }); + client.on("messageReactionRemove", async (reaction, user) => { + try { + await this.OnMessageReactionRemove(reaction, user); + } catch (err) { log.error("Exception thrown while handling \"messageReactionRemove\" event", err); } + }); + client.on("messageReactionRemoveAll", async (message) => { + try { + await this.OnMessageReactionRemoveAll(message); + } catch (err) { log.error("Exception thrown while handling \"messageReactionRemoveAll\" event", err); } + }); // Due to messages often arriving before we get a response from the send call, // messages get delayed from discord. We use Util.DelayedPromise to handle this. @@ -567,7 +582,7 @@ export class DiscordBot { return; } const link = `https://discord.com/channels/${chan.guild.id}/${chan.id}/${editEventId}`; - embedSet.messageEmbed.description = `[Edit](${link}): ${embedSet.messageEmbed.description}`; + embedSet.messageEmbed.description = `[Edit](<${link}>): ${embedSet.messageEmbed.description}`; await this.send(embedSet, opts, roomLookup, event); } catch (err) { // throw wrapError(err, Unstable.ForeignNetworkError, "Couldn't edit message"); @@ -1107,6 +1122,20 @@ export class DiscordBot { formatted_body: result.formattedBody, msgtype: result.msgtype, }; + if (msg.reference) { + const storeEvent = await this.store.Get(DbEvent, {discord_id: msg.reference?.messageID}) + if (storeEvent && storeEvent.Result) + { + while(storeEvent.Next()) + { + sendContent["m.relates_to"] = { + "m.in_reply_to": { + event_id: storeEvent.MatrixId.split(";")[0] + } + }; + } + } + } if (editEventId) { sendContent.body = `* ${result.body}`; sendContent.formatted_body = `* ${result.formattedBody}`; @@ -1177,6 +1206,146 @@ export class DiscordBot { await this.OnMessage(newMsg); } + public async OnMessageReactionAdd(reaction: Discord.MessageReaction, user: Discord.User | Discord.PartialUser) { + const message = reaction.message; + const reactionName = reaction.emoji.name; + log.verbose(`Got message reaction add event for ${message.id} with ${reactionName}`); + + let rooms: string[]; + + try { + rooms = await this.channelSync.GetRoomIdsFromChannel(message.channel); + } catch (err) { + log.verbose(`No bridged rooms to forward reaction to. Reaction Event: ${reaction}`); + MetricPeg.get.requestOutcome(message.id, true, "dropped"); + return; + } + + const intent = this.GetIntentFromDiscordMember(user); + await intent.ensureRegistered(); + this.userActivity.updateUserActivity(intent.userId); + + const storeEvent = await this.store.Get(DbEvent, { + discord_id: message.id + }); + + if (!storeEvent?.Result) { + log.verbose(`Received add reaction event for untracked message. Dropping! Reaction Event: ${reaction}`); + return; + } + + while (storeEvent.Next()) { + const matrixIds = storeEvent.MatrixId.split(";"); + + for (const room of rooms) { + const reactionEventId = await intent.underlyingClient.unstableApis.addReactionToEvent( + room, + matrixIds[0], + reaction.emoji.id ? `:${reactionName}:` : reactionName + ); + + const event = new DbEvent(); + event.MatrixId = `${reactionEventId};${room}`; + event.DiscordId = message.id; + event.ChannelId = message.channel.id; + if (message.guild) { + event.GuildId = message.guild.id; + } + + await this.store.Insert(event); + } + } + } + + public async OnMessageReactionRemove(reaction: Discord.MessageReaction, user: Discord.User | Discord.PartialUser) { + const message = reaction.message; + log.verbose(`Got message reaction remove event for ${message.id} with ${reaction.emoji.name}`); + + const intent = this.GetIntentFromDiscordMember(user); + await intent.ensureRegistered(); + this.userActivity.updateUserActivity(intent.userId); + + const storeEvent = await this.store.Get(DbEvent, { + discord_id: message.id, + }); + + if (!storeEvent?.Result) { + log.verbose(`Received remove reaction event for untracked message. Dropping! Reaction Event: ${reaction}`); + return; + } + + while (storeEvent.Next()) { + const [ eventId, roomId ] = storeEvent.MatrixId.split(";"); + const underlyingClient = intent.underlyingClient; + + const { chunk } = await underlyingClient.unstableApis.getRelationsForEvent( + roomId, + eventId, + "m.annotation" + ); + + const event = chunk.find((event) => { + if (event.sender !== intent.userId) { + return false; + } + + return event.content["m.relates_to"].key === reaction.emoji.name; + }); + + if (!event) { + log.verbose(`Received remove reaction event for tracked message where the add reaction event was not bridged. Dropping! Reaction Event: ${reaction}`); + return; + } + + const { room_id, event_id } = event; + + try { + await underlyingClient.redactEvent(room_id, event_id); + } catch (ex) { + log.warn(`Failed to delete ${storeEvent.DiscordId}, retrying as bot`); + try { + await this.bridge.botIntent.underlyingClient.redactEvent(room_id, event_id); + } catch (ex) { + log.warn(`Failed to delete ${event_id}, giving up`); + } + } + } + } + + public async OnMessageReactionRemoveAll(message: Discord.Message | Discord.PartialMessage) { + log.verbose(`Got message reaction remove all event for ${message.id}`); + + const storeEvent = await this.store.Get(DbEvent, { + discord_id: message.id, + }); + + if (!storeEvent?.Result) { + log.verbose(`Received remove all reaction event for untracked message. Dropping! Event: ${message}`); + return; + } + + while (storeEvent.Next()) { + const [ eventId, roomId ] = storeEvent.MatrixId.split(";"); + const underlyingClient = this.bridge.botIntent.underlyingClient; + + const { chunk } = await underlyingClient.unstableApis.getRelationsForEvent( + roomId, + eventId, + "m.annotation" + ); + + const filteredChunk = chunk.filter((event) => event.sender === this.bridge.botUserId); + + await Promise.all(filteredChunk.map(async (event) => { + try { + return await underlyingClient.redactEvent(event.room_id, event.event_id); + } catch (ex) { + log.warn(`Failed to delete ${event.event_id}, giving up`); + } + })); + } + } + private async DeleteDiscordMessage(msg: Discord.Message) { log.info(`Got delete event for ${msg.id}`); const storeEvent = await this.store.Get(DbEvent, {discord_id: msg.id}); diff --git a/src/db/dbdataevent.ts b/src/db/dbdataevent.ts index d3fc58f7c90154017884e63bc015b8f386d41174..5d8fd9e545be5f176005b9830d35ad12433bc855 100644 --- a/src/db/dbdataevent.ts +++ b/src/db/dbdataevent.ts @@ -19,7 +19,9 @@ import { IDbDataMany } from "./dbdatainterface"; import { ISqlCommandParameters } from "./connector"; export class DbEvent implements IDbDataMany { + /** ID associated with the event in the format "MatrixID:RoomID". */ public MatrixId: string; + /** Discord ID of the relevant message associated with this event. */ public DiscordId: string; public GuildId: string; public ChannelId: string; diff --git a/src/discordmessageprocessor.ts b/src/discordmessageprocessor.ts index b7a2fb2b06923de54f0571881d39ebf3cfc9e69c..d09f4597ee6407e8deeb147b60ef286f7440022c 100644 --- a/src/discordmessageprocessor.ts +++ b/src/discordmessageprocessor.ts @@ -22,7 +22,7 @@ import { IDiscordMessageParserOpts, IDiscordMessageParserCallbacks, IDiscordMessageParserResult, -} from "@mx-puppet/matrix-discord-parser"; +} from "@deurstann/matrix-discord-parser"; const log = new Log("DiscordMessageProcessor"); diff --git a/src/matrixcommandhandler.ts b/src/matrixcommandhandler.ts index 29abf4dd611050d927a92e5372c1877d0a86d4c5..09e3c13c08e6c51175f1c36489f5718269281392 100644 --- a/src/matrixcommandhandler.ts +++ b/src/matrixcommandhandler.ts @@ -72,7 +72,9 @@ export class MatrixCommandHandler { cat: "events", level: PROVISIONING_DEFAULT_POWER_LEVEL, selfService: true, - subcat: "m.room.power_levels", + //subcat: "m.room.power_levels", + // use default power level (50) + subcat: "null", }, run: async ({guildId, channelId}) => { if (roomEntry && roomEntry.remote) { @@ -118,7 +120,7 @@ export class MatrixCommandHandler { cat: "events", level: PROVISIONING_DEFAULT_POWER_LEVEL, selfService: true, - subcat: "m.room.power_levels", + subcat: "null", }, run: async () => { if (!roomEntry || !roomEntry.remote) { diff --git a/src/matrixeventprocessor.ts b/src/matrixeventprocessor.ts index f1f46115c48e761dec66bbaad476a32a8cb9d5b1..fee2c302fbde48d9676b082b12a166d90bd44ae7 100644 --- a/src/matrixeventprocessor.ts +++ b/src/matrixeventprocessor.ts @@ -533,7 +533,7 @@ export class MatrixEventProcessor { embed.setAuthor( displayName.substring(0, MAX_NAME_LENGTH), avatarUrl, - `https://matrix.to/#/${sender}`, + ``, ); } diff --git a/src/matrixmessageprocessor.ts b/src/matrixmessageprocessor.ts index e8fc7370886f26a96993bfdc9dc03c0a84275977..74ec6f9d056ef6d368c9d6c8d20144892a98617f 100644 --- a/src/matrixmessageprocessor.ts +++ b/src/matrixmessageprocessor.ts @@ -24,7 +24,7 @@ import { IMatrixMessageParserCallbacks, IMatrixMessageParserOpts, MatrixMessageParser, -} from "@mx-puppet/matrix-discord-parser"; +} from "@deurstann/matrix-discord-parser"; const DEFAULT_ROOM_NOTIFY_POWER_LEVEL = 50; diff --git a/src/usersyncroniser.ts b/src/usersyncroniser.ts index 51805200672eb67f4ea4d424896b10dde89f7036..43f7b3e90b4b8cd427da567945342d6205802dad 100644 --- a/src/usersyncroniser.ts +++ b/src/usersyncroniser.ts @@ -317,7 +317,7 @@ export class UserSyncroniser { // for webhooks we append the username to the mxid, as webhooks with the same // id can have multiple different usernames set. This way we don't spam // userstate changes - mxidExtra = "_" + Util.ParseMxid(`@${user.username}`, false).localpart; + mxidExtra = "_" + Util.ParseMxid(`@${user.username}`).localpart; } const guildState: IGuildMemberState = Object.assign({}, DEFAULT_GUILD_STATE, { bot: user.bot, diff --git a/src/util.ts b/src/util.ts index 82bd985bd27c75ec133b7241ad6dbc20c4dd3f87..a22b1e2cdfa3a0164a9e2270b1585b11ca2ff249 100644 --- a/src/util.ts +++ b/src/util.ts @@ -365,10 +365,7 @@ export class Util { const badChars = new Set(localpart.replace(/([a-z0-9]|-|\.|=|_)+/g, "")); badChars.forEach((c) => { const hex = c.charCodeAt(0).toString(RADIX).toLowerCase(); - localpart = localpart.replace( - new RegExp(`\\${c}`, "g"), - `=${hex}`, - ); + localpart = localpart.replaceAll(c, `=${hex}`); }); } return { @@ -380,17 +377,7 @@ export class Util { // Taken from https://github.com/matrix-org/matrix-appservice-bridge/blob/master/lib/models/users/matrix.js public static EscapeStringForUserId(localpart: string) { - // NOTE: Currently Matrix accepts / in the userId, although going forward it will be removed. - const badChars = new Set(localpart.replace(/([a-z]|[0-9]|-|\.|=|_)+/g, "")); - let res = localpart; - badChars.forEach((c) => { - const hex = c.charCodeAt(0).toString(16).toLowerCase(); - res = res.replace( - new RegExp(`\\x${hex}`, "g"), - `=${hex}`, - ); - }); - return res; + return localpart.replace(/[^a-z0-9-._]/g, a => `=${a.codePointAt(0)!.toString(16).toLowerCase()}`) } } diff --git a/test/mocks/appservicemock.ts b/test/mocks/appservicemock.ts index 28eb3935b9a4fa5e66f89604278a8b6f11bcf74b..d7820e2d18d70fc4cdeb62828a9b7fa0872f3e2f 100644 --- a/test/mocks/appservicemock.ts +++ b/test/mocks/appservicemock.ts @@ -146,7 +146,7 @@ export class AppserviceMock extends AppserviceMockBase { class IntentMock extends AppserviceMockBase { public readonly underlyingClient: MatrixClientMock; - constructor(private opts: IAppserviceMockOpts = {}, private id: string) { + constructor(private opts: IAppserviceMockOpts = {}, public userId: string) { super(); this.underlyingClient = new MatrixClientMock(opts); } @@ -177,9 +177,10 @@ class IntentMock extends AppserviceMockBase { } class MatrixClientMock extends AppserviceMockBase { - + public readonly unstableApis: UnstableApis;; constructor(private opts: IAppserviceMockOpts = {}) { super(); + this.unstableApis = new UnstableApis(); } public banUser(roomId: string, userId: string) { @@ -276,4 +277,19 @@ class MatrixClientMock extends AppserviceMockBase { public async setPresenceStatus(presence: string, status: string) { this.funcCalled("setPresenceStatus", presence, status); } + + public async redactEvent(roomId: string, eventId: string, reason?: string | null) { + this.funcCalled("redactEvent", roomId, eventId, reason); + } +} + +class UnstableApis extends AppserviceMockBase { + + public async addReactionToEvent(roomId: string, eventId: string, emoji: string) { + this.funcCalled("addReactionToEvent", roomId, eventId, emoji); + } + + public async getRelationsForEvent(roomId: string, eventId: string, relationType?: string, eventType?: string): Promise<any> { + this.funcCalled("getRelationsForEvent", roomId, eventId, relationType, eventType); + } } diff --git a/test/mocks/message.ts b/test/mocks/message.ts index e937c85b5c501df40cc14f6da6f0f310424551f8..c9ec0cbbb60eaf4efa94e28d0fbc8129e61af44c 100644 --- a/test/mocks/message.ts +++ b/test/mocks/message.ts @@ -24,17 +24,23 @@ import { MockCollection } from "./collection"; export class MockMessage { public attachments = new MockCollection<string, any>(); public embeds: any[] = []; - public content = ""; + public content: string; public channel: Discord.TextChannel | undefined; public guild: Discord.Guild | undefined; public author: MockUser; public mentions: any = {}; - constructor(channel?: Discord.TextChannel) { + + constructor( + channel?: Discord.TextChannel, + content: string = "", + author: MockUser = new MockUser("123456"), + ) { this.mentions.everyone = false; this.channel = channel; if (channel && channel.guild) { this.guild = channel.guild; } - this.author = new MockUser("123456"); + this.content = content; + this.author = author; } } diff --git a/test/mocks/reaction.ts b/test/mocks/reaction.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ca85555fa4f7322956ca2f15c2b1cc6d28a05fc --- /dev/null +++ b/test/mocks/reaction.ts @@ -0,0 +1,16 @@ +import { MockTextChannel } from './channel'; +import { MockEmoji } from './emoji'; +import { MockMessage } from './message'; + +/* tslint:disable:no-unused-expression max-file-line-count no-any */ +export class MockReaction { + public message: MockMessage; + public emoji: MockEmoji; + public channel: MockTextChannel; + + constructor(message: MockMessage, emoji: MockEmoji, channel: MockTextChannel) { + this.message = message; + this.emoji = emoji; + this.channel = channel; + } +} diff --git a/test/test_discordbot.ts b/test/test_discordbot.ts index 9d91dfeff10e44279db359729853358d5b1daa41..c89a0cd0dce04c38bc305ee548c0ca136ac226de 100644 --- a/test/test_discordbot.ts +++ b/test/test_discordbot.ts @@ -25,6 +25,8 @@ import { Util } from "../src/util"; import { AppserviceMock } from "./mocks/appservicemock"; import { MockUser } from "./mocks/user"; import { MockTextChannel } from "./mocks/channel"; +import { MockReaction } from './mocks/reaction'; +import { MockEmoji } from './mocks/emoji'; // we are a test file and thus need those /* tslint:disable:no-unused-expression max-file-line-count no-any */ @@ -442,4 +444,82 @@ describe("DiscordBot", () => { expect(expected).to.eq(ITERATIONS); }); }); + describe("OnMessageReactionAdd", () => { + const channel = new MockTextChannel(); + const author = new MockUser("11111"); + const message = new MockMessage(channel, "Hello, World!", author); + const emoji = new MockEmoji("", "🤔"); + const reaction = new MockReaction(message, emoji, channel); + + function getDiscordBot() { + mockBridge.cleanup(); + const discord = new modDiscordBot.DiscordBot( + config, + mockBridge, + {}, + ); + discord.channelSync = { + GetRoomIdsFromChannel: async () => ["!asdf:localhost"], + }; + discord.store = { + Get: async () => { + let storeMockResults = 0; + + return { + Result: true, + MatrixId: "$mAKet_w5WYFCgh1WaHVOvyn9LJLbolFeuELTKVfm0Po;!asdf:localhost", + Next: () => storeMockResults++ === 0 + } + }, + Insert: async () => { }, + }; + discord.userActivity = { + updateUserActivity: () => { } + }; + discord.GetIntentFromDiscordMember = () => { + return mockBridge.getIntent(author.id); + } + return discord; + } + + it("Adds reaction from Discord → Matrix", async () => { + discordBot = getDiscordBot(); + await discordBot.OnMessageReactionAdd(reaction, author); + mockBridge.getIntent(author.id).underlyingClient.unstableApis.wasCalled( + "addReactionToEvent", + true, + "!asdf:localhost", + "$mAKet_w5WYFCgh1WaHVOvyn9LJLbolFeuELTKVfm0Po", + "🤔" + ); + }); + + it("Removes reaction from Discord → Matrix", async () => { + discordBot = getDiscordBot(); + const intent = mockBridge.getIntent(author.id); + + intent.underlyingClient.unstableApis.getRelationsForEvent = async () => { + return { + chunk: [ + { + sender: "11111", + room_id: "!asdf:localhost", + event_id: "$mAKet_w5WYFCgh1WaHVOvyn9LJLbolFeuELTKVfm0Po", + content: { + "m.relates_to": { key: "🤔" } + } + } + ] + } + } + + await discordBot.OnMessageReactionRemove(reaction, author); + intent.underlyingClient.wasCalled( + "redactEvent", + false, + "!asdf:localhost", + "$mAKet_w5WYFCgh1WaHVOvyn9LJLbolFeuELTKVfm0Po", + ); + }); + }); }); diff --git a/tsconfig.json b/tsconfig.json index d8273e20684e78cffa6202d9d332cdc835583039..b2d7054f301ae3fe6d653423f606c19f17022556 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "module": "commonjs", "moduleResolution": "node", - "target": "es2016", + "target": "es2021", "noImplicitAny": false, "inlineSourceMap": true, "outDir": "./build", diff --git a/yarn.lock b/yarn.lock index 25a1f0a799d8aacd9ece1a6297549661d592de50..ea30c1c1597fedf7deb931442576b0eda2bdc314 100644 --- a/yarn.lock +++ b/yarn.lock @@ -215,6 +215,18 @@ enabled "2.0.x" kuler "^2.0.0" +"@deurstann/matrix-discord-parser@1.10.7": + version "1.10.7" + resolved "https://registry.yarnpkg.com/@deurstann/matrix-discord-parser/-/matrix-discord-parser-1.10.7.tgz#570d0563c3f916faed877cdf6c06be0f3cf607ee" + integrity sha512-Ud/KKGkCdsDMoBW2UZNwiR5UNvkmJJJODAmsSQL/vXh9Iz7zfLqnI/cbP4nUrv1Txd5XqGi/pGSNnZ5Kd6AreQ== + dependencies: + "@mx-puppet/discord-markdown" "2.3.1" + escape-html "^1.0.3" + got "^11.6.0" + highlight.js "^10.4.1" + node-html-parser "^1.4.5" + unescape-html "^1.1.0" + "@discordjs/collection@^0.1.6": version "0.1.6" resolved "https://registry.yarnpkg.com/@discordjs/collection/-/collection-0.1.6.tgz#9e9a7637f4e4e0688fd8b2b5c63133c91607682c" @@ -337,18 +349,6 @@ node-emoji "^1.10.0" simple-markdown "^0.7.2" -"@mx-puppet/matrix-discord-parser@0.1.10": - version "0.1.10" - resolved "https://gitlab.com/api/v4/projects/35066311/packages/npm/@mx-puppet/matrix-discord-parser/-/@mx-puppet/matrix-discord-parser-0.1.10.tgz#0a37a3f9430ff7c29512d29882e25ae738a31283" - integrity sha1-Cjej+UMP98KVEtKYguJa5zijEoM= - dependencies: - "@mx-puppet/discord-markdown" "2.3.1" - escape-html "^1.0.3" - got "^11.6.0" - highlight.js "^10.4.1" - node-html-parser "^1.4.5" - unescape-html "^1.1.0" - "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"