diff --git a/README.md b/README.md index 5179f01a..089ce789 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 00000000..a693baaf --- /dev/null +++ b/changelog.d/862.feature @@ -0,0 +1 @@ +Adds one-way reaction support from Discord -> Matrix. Thanks to @SethFalco! diff --git a/src/bot.ts b/src/bot.ts index 8bc73d41..a5a8756f 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -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. @@ -1177,6 +1192,139 @@ export class DiscordBot { await this.OnMessage(newMsg); } + public async OnMessageReactionAdd(reaction: Discord.MessageReaction, user: Discord.User | Discord.PartialUser) { + const message = reaction.message; + log.info(`Got message reaction add event for ${message.id} with ${reaction.emoji.name}`); + + let rooms: string[]; + + try { + rooms = await this.channelSync.GetRoomIdsFromChannel(message.channel); + + if (rooms === null) { + throw Error(); + } + } catch (err) { + log.verbose("No bridged rooms to send message to. Oh well."); + 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) { + return; + } + + while (storeEvent.Next()) { + const matrixIds = storeEvent.MatrixId.split(";"); + + for (const room of rooms) { + const eventId = await intent.underlyingClient.unstableApis.addReactionToEvent( + room, + matrixIds[0], + reaction.emoji.name + ); + + const event = new DbEvent(); + event.MatrixId = `${eventId};${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.info(`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) { + return; + } + + while (storeEvent.Next()) { + const [ eventId, roomId ] = storeEvent.MatrixId.split(";"); + + const { chunk } = await intent.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) { + return; + } + + try { + await intent.underlyingClient.redactEvent(event.room_id, event.event_id); + } catch (ex) { + log.warn(`Failed to delete ${storeEvent.DiscordId}, retrying as bot`); + try { + await this.bridge.botIntent.underlyingClient.redactEvent(event.room_id, event.event_id); + } catch (ex) { + log.warn(`Failed to delete ${storeEvent.DiscordId}, giving up`); + } + } + } + } + + public async OnMessageReactionRemoveAll(message: Discord.Message | Discord.PartialMessage) { + log.info(`Got message reaction remove all event for ${message.id}`); + + const storeEvent = await this.store.Get(DbEvent, { + discord_id: message.id, + }); + + if (!storeEvent?.Result) { + return; + } + + while (storeEvent.Next()) { + const [ eventId, roomId ] = storeEvent.MatrixId.split(";"); + + const { chunk } = await this.bridge.botIntent.underlyingClient.unstableApis.getRelationsForEvent( + roomId, + eventId, + "m.annotation" + ); + + await Promise.all(chunk.map(async (event) => { + try { + return await this.bridge.botIntent.underlyingClient.redactEvent(event.room_id, event.event_id); + } catch (ex) { + log.warn(`Failed to delete ${storeEvent.DiscordId}, 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 d3fc58f7..38738a55 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 { + /** Matrix ID of event. */ 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/test/mocks/appservicemock.ts b/test/mocks/appservicemock.ts index 28eb3935..d7820e2d 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 { + this.funcCalled("getRelationsForEvent", roomId, eventId, relationType, eventType); + } } diff --git a/test/mocks/message.ts b/test/mocks/message.ts index e937c85b..c9ec0cbb 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(); 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 00000000..7ca85555 --- /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 9d91dfef..c89a0cd0 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", + ); + }); + }); });