From 7cc13a3830b16a523ee6f0377c5201f0f7984184 Mon Sep 17 00:00:00 2001 From: Grant Date: Sun, 15 Dec 2024 16:11:43 -0700 Subject: [PATCH] message reactions --- .../migrations/20241215152732_/migration.sql | 8 + .../migrations/20241215165248_/migration.sql | 28 + .../migrations/20241215171318_/migration.sql | 25 + .../migrations/20241215192653_/migration.sql | 5 + .../migrations/20241215201819_/migration.sql | 10 + prisma/schema.prisma | 17 + src/discord.ts | 662 +++++++++++++++++- src/lib/matrix.ts | 38 + src/matrix.ts | 102 ++- src/types/env.ts | 1 + src/types/matrix.ts | 8 + tools/register-interactions.ts | 41 ++ 12 files changed, 943 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20241215152732_/migration.sql create mode 100644 prisma/migrations/20241215165248_/migration.sql create mode 100644 prisma/migrations/20241215171318_/migration.sql create mode 100644 prisma/migrations/20241215192653_/migration.sql create mode 100644 prisma/migrations/20241215201819_/migration.sql create mode 100644 tools/register-interactions.ts diff --git a/prisma/migrations/20241215152732_/migration.sql b/prisma/migrations/20241215152732_/migration.sql new file mode 100644 index 0000000..aa35a1a --- /dev/null +++ b/prisma/migrations/20241215152732_/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "MatrixReactions" ( + "id" TEXT NOT NULL PRIMARY KEY, + "matrixMessageId" TEXT, + "discordMessageId" TEXT, + CONSTRAINT "MatrixReactions_matrixMessageId_fkey" FOREIGN KEY ("matrixMessageId") REFERENCES "MatrixMessages" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "MatrixReactions_discordMessageId_fkey" FOREIGN KEY ("discordMessageId") REFERENCES "DiscordMessages" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); diff --git a/prisma/migrations/20241215165248_/migration.sql b/prisma/migrations/20241215165248_/migration.sql new file mode 100644 index 0000000..7165e87 --- /dev/null +++ b/prisma/migrations/20241215165248_/migration.sql @@ -0,0 +1,28 @@ +/* + Warnings: + + - You are about to drop the column `discordMessageId` on the `MatrixReactions` table. All the data in the column will be lost. + - You are about to drop the column `matrixMessageId` on the `MatrixReactions` table. All the data in the column will be lost. + - Added the required column `matrix_author_id` to the `MatrixReactions` table without a default value. This is not possible if the table is not empty. + - Added the required column `matrix_event_id` to the `MatrixReactions` table without a default value. This is not possible if the table is not empty. + - Added the required column `matrix_room_id` to the `MatrixReactions` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_MatrixReactions" ( + "id" TEXT NOT NULL PRIMARY KEY, + "matrix_event_id" TEXT NOT NULL, + "matrix_room_id" TEXT NOT NULL, + "matrix_author_id" TEXT NOT NULL, + "matrix_message_id" TEXT, + "discord_message_id" TEXT, + CONSTRAINT "MatrixReactions_matrix_message_id_fkey" FOREIGN KEY ("matrix_message_id") REFERENCES "MatrixMessages" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "MatrixReactions_discord_message_id_fkey" FOREIGN KEY ("discord_message_id") REFERENCES "DiscordMessages" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_MatrixReactions" ("id") SELECT "id" FROM "MatrixReactions"; +DROP TABLE "MatrixReactions"; +ALTER TABLE "new_MatrixReactions" RENAME TO "MatrixReactions"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20241215171318_/migration.sql b/prisma/migrations/20241215171318_/migration.sql new file mode 100644 index 0000000..644992b --- /dev/null +++ b/prisma/migrations/20241215171318_/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - Added the required column `reaction` to the `MatrixReactions` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_MatrixReactions" ( + "id" TEXT NOT NULL PRIMARY KEY, + "matrix_event_id" TEXT NOT NULL, + "matrix_room_id" TEXT NOT NULL, + "matrix_author_id" TEXT NOT NULL, + "reaction" TEXT NOT NULL, + "matrix_message_id" TEXT, + "discord_message_id" TEXT, + CONSTRAINT "MatrixReactions_matrix_message_id_fkey" FOREIGN KEY ("matrix_message_id") REFERENCES "MatrixMessages" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "MatrixReactions_discord_message_id_fkey" FOREIGN KEY ("discord_message_id") REFERENCES "DiscordMessages" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_MatrixReactions" ("discord_message_id", "id", "matrix_author_id", "matrix_event_id", "matrix_message_id", "matrix_room_id") SELECT "discord_message_id", "id", "matrix_author_id", "matrix_event_id", "matrix_message_id", "matrix_room_id" FROM "MatrixReactions"; +DROP TABLE "MatrixReactions"; +ALTER TABLE "new_MatrixReactions" RENAME TO "MatrixReactions"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20241215192653_/migration.sql b/prisma/migrations/20241215192653_/migration.sql new file mode 100644 index 0000000..1db0f5d --- /dev/null +++ b/prisma/migrations/20241215192653_/migration.sql @@ -0,0 +1,5 @@ +-- CreateTable +CREATE TABLE "LinkedEmojis" ( + "id" TEXT NOT NULL PRIMARY KEY, + "discord_emoji_id" TEXT NOT NULL +); diff --git a/prisma/migrations/20241215201819_/migration.sql b/prisma/migrations/20241215201819_/migration.sql new file mode 100644 index 0000000..c13099c --- /dev/null +++ b/prisma/migrations/20241215201819_/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the `LinkedEmojis` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "LinkedEmojis"; +PRAGMA foreign_keys=on; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6c13778..8102c61 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,6 +37,7 @@ model DiscordMessages { matrix_event_id String matrix_room_id String + MatrixReactions MatrixReactions[] } // matrix -> discord @@ -50,4 +51,20 @@ model MatrixMessages { discord_id String discord_channel_id String discord_guild_id String + MatrixReactions MatrixReactions[] +} + +// reactions coming from matrix +model MatrixReactions { + id String @id @default(uuid()) + + matrix_event_id String + matrix_room_id String + matrix_author_id String + reaction String + + from_matrix MatrixMessages? @relation(fields: [matrix_message_id], references: [id]) + matrix_message_id String? + from_discord DiscordMessages? @relation(fields: [discord_message_id], references: [id]) + discord_message_id String? } diff --git a/src/discord.ts b/src/discord.ts index cbbe1cc..1e9bc0e 100644 --- a/src/discord.ts +++ b/src/discord.ts @@ -6,11 +6,19 @@ import { ChannelType, Client, EmbedBuilder, + EmojiIdentifierResolvable, GatewayIntentBits, + GuildEmoji, + Interaction, Message, + MessageContextMenuCommandInteraction, + MessageReaction, MessageType, PartialMessage, + PartialMessageReaction, + PartialUser, Partials, + ReactionEmoji, Typing, User, Webhook, @@ -21,6 +29,7 @@ import { prisma } from "./lib/prisma"; import { IMatrixUser, MXC, + MatrixReactionEvent, MatrixRoomMessage, MatrixStickerEvent, } from "./types/matrix"; @@ -29,6 +38,12 @@ import { FriendlyError } from "./lib/utils"; import { Turndown } from "./lib/turndown"; import { Glue } from "./lib/glue"; +const BOT_EMOJIS = { + discord_matrix: "<:_:1317980820098781224>", + discord: "<:_:1317980807431983184>", + matrix: "<:_:1317980771348385822>", +}; + class Discord_ { client = new Client({ intents: [ @@ -40,7 +55,7 @@ class Discord_ { GatewayIntentBits.GuildMessageTyping, GatewayIntentBits.GuildMessageReactions, ], - partials: [Partials.Message], + partials: [Partials.Message, Partials.Reaction], }); constructor() { @@ -48,6 +63,20 @@ class Discord_ { this.client.on("messageUpdate", this.handleMessageUpdate.bind(this)); this.client.on("messageDelete", this.handleMessageDelete.bind(this)); this.client.on("typingStart", this.handleTypingStart.bind(this)); + this.client.on("interactionCreate", this.handleInteraction.bind(this)); + this.client.on("messageReactionAdd", this.handleReactionAdd.bind(this)); + this.client.on( + "messageReactionRemove", + this.handleReactionRemove.bind(this) + ); + this.client.on( + "messageReactionRemoveAll", + this.handleReactionRemoveAll.bind(this) + ); + this.client.on( + "messageReactionRemoveEmoji", + this.handleReactionRemoveEmoji.bind(this) + ); this.client.on("ready", () => { console.log(`Logged in as ${this.client.user?.tag}`); @@ -115,6 +144,497 @@ class Discord_ { MessageType.Reply, ]; + /** + * Convert Discord reaction to Matrix representation (or database rep) + * @param reaction + * @returns + */ + private discordReactionToMatrix( + reaction: MessageReaction | PartialMessageReaction + ) { + return reaction.emoji.id + ? this.getBridgeProxy( + "emoji", + reaction.emoji.animated || false, + reaction.emoji.id + ) + : reaction.emoji.name || reaction.emoji.identifier; + } + + async handleReactionAdd( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser + ) { + if (user.bot) return; + const matrix = await Matrix.for(`_discord_${user.id}`); + const matrixReaction = this.discordReactionToMatrix(reaction); + + { + // Discord -> Matrix messages + + const messages = await prisma.discordMessages.findMany({ + where: { + discord_guild_id: reaction.message.guildId!, + discord_channel_id: reaction.message.channelId, + discord_id: reaction.message.id, + }, + }); + + for (const message of messages) { + await Matrix.ensureUserInRoom(message.matrix_room_id, matrix.username); + const reaction = await matrix.addReaction( + message.matrix_room_id, + message.matrix_event_id, + matrixReaction + ); + + // TODO: handle errors + if (reaction.status !== 200) continue; + + await prisma.matrixReactions.create({ + data: { + matrix_event_id: reaction.data.event_id, + matrix_author_id: matrix.fqUsername(), + matrix_room_id: message.matrix_room_id, + reaction: matrixReaction, + from_discord: { + connect: message, + }, + }, + }); + } + } + + { + // Matrix -> Discord messages + + const messages = await prisma.matrixMessages.findMany({ + where: { + discord_guild_id: reaction.message.guildId!, + discord_channel_id: reaction.message.channelId, + discord_id: reaction.message.id, + }, + }); + + for (const message of messages) { + await Matrix.ensureUserInRoom(message.matrix_room_id, matrix.username); + const reaction = await matrix.addReaction( + message.matrix_room_id, + message.matrix_event_id, + matrixReaction + ); + + // TODO: handle errors + if (reaction.status !== 200) continue; + + await prisma.matrixReactions.create({ + data: { + matrix_event_id: reaction.data.event_id, + matrix_author_id: matrix.fqUsername(), + matrix_room_id: message.matrix_room_id, + reaction: matrixReaction, + from_matrix: { + connect: message, + }, + }, + }); + } + } + } + + async handleReactionRemove( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser + ) { + if (user.bot) return; + const matrix = await Matrix.for(`_discord_${user.id}`); + const matrixReaction = this.discordReactionToMatrix(reaction); + + const reactions = await prisma.matrixReactions.findMany({ + where: { + from_discord: { + discord_guild_id: reaction.message.guildId!, + discord_channel_id: reaction.message.channelId, + discord_id: reaction.message.id, + }, + matrix_author_id: matrix.fqUsername(), + reaction: matrixReaction, + }, + }); + + for (const reaction of reactions) { + await Matrix.ensureUserInRoom(reaction.matrix_room_id, matrix.username); + + await prisma.matrixReactions.delete({ + where: { + id: reaction.id, + }, + }); + + await matrix.redactEvent( + reaction.matrix_room_id, + reaction.matrix_event_id, + Math.floor(Math.random() * 1000) + "" + ); + } + } + + /** + * Moderation action to remove all reactions + * @param message + */ + async handleReactionRemoveAll(message: Message | PartialMessage) { + const matrix = await Matrix.for(`_discord_bot`); + + { + // Discord -> Matrix messages + + const messages = await prisma.discordMessages.findMany({ + where: { + discord_guild_id: message.guildId!, + discord_channel_id: message.channelId, + discord_id: message.id, + }, + }); + + for (const message of messages) { + const allReactions = await matrix.getMessageReactions( + message.matrix_room_id, + message.matrix_event_id + ); + + // TODO: handle errors + if (allReactions.status !== 200) continue; + + const toRedact = allReactions.data.chunk; + + for (const event of toRedact) { + await matrix.redactEvent( + message.matrix_room_id, + event.event_id, + Math.floor(Math.random() * 1000) + "" + ); + } + } + } + + { + // Matrix -> Discord messages + + const messages = await prisma.matrixMessages.findMany({ + where: { + discord_guild_id: message.guildId!, + discord_channel_id: message.channelId, + discord_id: message.id, + }, + }); + + for (const message of messages) { + const allReactions = await matrix.getMessageReactions( + message.matrix_room_id, + message.matrix_event_id + ); + + // TODO: handle errors + if (allReactions.status !== 200) continue; + + const toRedact = allReactions.data.chunk; + + for (const event of toRedact) { + await matrix.redactEvent( + message.matrix_room_id, + event.event_id, + Math.floor(Math.random() * 1000) + "" + ); + } + } + } + } + + /** + * Moderation action to remove all reactions + * @param reaction + */ + async handleReactionRemoveEmoji( + reaction: MessageReaction | PartialMessageReaction + ) { + const matrix = await Matrix.for(`_discord_bot`); + const matrixReaction = this.discordReactionToMatrix(reaction); + + { + // Discord -> Matrix messages + + const messages = await prisma.discordMessages.findMany({ + where: { + discord_guild_id: reaction.message.guildId!, + discord_channel_id: reaction.message.channelId, + discord_id: reaction.message.id, + }, + }); + + for (const message of messages) { + const allReactions = await matrix.getMessageReactions( + message.matrix_room_id, + message.matrix_event_id + ); + + // TODO: handle errors + if (allReactions.status !== 200) continue; + + const toRedact = allReactions.data.chunk.filter( + (chunk) => chunk.content["m.relates_to"].key === matrixReaction + ); + + for (const event of toRedact) { + await matrix.redactEvent( + message.matrix_room_id, + event.event_id, + Math.floor(Math.random() * 1000) + "" + ); + } + } + } + + { + // Matrix -> Discord messages + + const messages = await prisma.matrixMessages.findMany({ + where: { + discord_guild_id: reaction.message.guildId!, + discord_channel_id: reaction.message.channelId, + discord_id: reaction.message.id, + }, + }); + + for (const message of messages) { + const allReactions = await matrix.getMessageReactions( + message.matrix_room_id, + message.matrix_event_id + ); + + // TODO: handle errors + if (allReactions.status !== 200) continue; + + const toRedact = allReactions.data.chunk.filter( + (chunk) => chunk.content["m.relates_to"].key === matrixReaction + ); + + for (const event of toRedact) { + await matrix.redactEvent( + message.matrix_room_id, + event.event_id, + Math.floor(Math.random() * 1000) + "" + ); + } + } + } + } + + async handleInteraction(interaction: Interaction) { + if ( + interaction.isContextMenuCommand() && + interaction.isMessageContextMenuCommand() + ) { + switch (interaction.commandName) { + case "view_status": + await this.interaction_viewStatus(interaction); + break; + case "view_reactions": + await this.interaction_viewReactions(interaction); + break; + } + } + } + + async interaction_viewStatus( + interaction: MessageContextMenuCommandInteraction + ) { + // Discord -> Matrix messages + const dc_messages = await prisma.discordMessages.findMany({ + where: { + discord_id: interaction.targetMessage.id, + discord_channel_id: interaction.targetMessage.channelId, + discord_guild_id: interaction.targetMessage.guildId!, + }, + }); + + // Matrix -> Discord messages + const mx_messages = await prisma.matrixMessages.findMany({ + where: { + discord_id: interaction.targetMessage.id, + discord_channel_id: interaction.targetMessage.channelId, + discord_guild_id: interaction.targetMessage.guildId!, + }, + }); + + const embed = new EmbedBuilder().setColor("DarkAqua").addFields([ + { + name: `${BOT_EMOJIS.discord} Messages:`, + value: + dc_messages + .map( + (m) => + `https://discord.com/channels/${m.discord_guild_id}/${m.discord_channel_id}/${m.discord_id}` + ) + .join("\n") || "-# none", + inline: true, + }, + { + name: `${BOT_EMOJIS.matrix} Messages:`, + value: + mx_messages + .map( + (m) => + `[matrix.to](${Matrix.matrixLink(m.matrix_room_id, m.matrix_event_id)})` + ) + .join("\n") || "-# none", + inline: true, + }, + ]); + + await interaction.reply({ + embeds: [embed], + ephemeral: true, + }); + } + + async interaction_viewReactions( + interaction: MessageContextMenuCommandInteraction + ) { + // this interaction should show everyone that reacted (including matrix & discord reactions) + const matrix = await Matrix.for(`_discord_bot`); + + let mx_reactions: MatrixReactionEvent[] = []; + let dc_reactions = [...interaction.targetMessage.reactions.cache.values()]; + + { + // Discord -> Matrix messages + + const messages = await prisma.discordMessages.findMany({ + where: { + discord_id: interaction.targetMessage.id, + discord_channel_id: interaction.targetMessage.channelId, + discord_guild_id: interaction.targetMessage.guildId!, + }, + }); + + for (const message of messages) { + try { + const resp = await matrix.getMessageReactions( + message.matrix_room_id, + message.matrix_event_id + ); + + if (resp.status === 200) { + mx_reactions.push(...resp.data.chunk); + } + } catch (e) { + console.error("Failed to get reactions", e); + continue; + } + } + } + + { + // Matrix -> Discord messages + + const messages = await prisma.matrixMessages.findMany({ + where: { + discord_id: interaction.targetMessage.id, + discord_channel_id: interaction.targetMessage.channelId, + discord_guild_id: interaction.targetMessage.guildId!, + }, + }); + + for (const message of messages) { + try { + const resp = await matrix.getMessageReactions( + message.matrix_room_id, + message.matrix_event_id + ); + + if (resp.status === 200) { + mx_reactions.push(...resp.data.chunk); + } + } catch (e) { + console.error("Failed to get reactions", e); + continue; + } + } + } + + const uniqueKeys = new Set([ + ...mx_reactions.map((r) => r.content["m.relates_to"].key), + ...dc_reactions.map((r) => this.discordReactionToMatrix(r)), + ]); + + enum ReactionOrigin { + Discord, + Matrix, + } + const reactions = new Map(); + + for (const key of uniqueKeys) { + let set: { type: ReactionOrigin; id: string }[] = []; + set.push( + ...mx_reactions + .filter( + (r) => + r.content["m.relates_to"].key === key && + !r.sender.startsWith("@_discord") + ) + .map((r) => ({ + type: ReactionOrigin.Matrix, + id: r.sender, + })) + ); + + const dc_reaction = dc_reactions.find( + (r) => this.discordReactionToMatrix(r) === key + ); + if (dc_reaction) { + const users = [...(await dc_reaction.users.fetch()).values()].filter( + (u) => !u.bot + ); + set.push( + ...users.map((u) => ({ + type: ReactionOrigin.Discord, + id: u.id, + })) + ); + } + + reactions.set(key, set); + } + + // Matrix/Discord bot emoji + const lines = ["<:_:1317980820098781224> **Reactions:**"]; + + for (const [key, users] of reactions) { + const display = + (await this.emoji_matrixToDiscord(key as any))?.toString() || key; + + lines.push( + `> ${display} (${users.length}): ${users + .map((u) => { + if (u.type === ReactionOrigin.Discord) { + // Discord bot emoji + return `<:_:1317980807431983184> <@${u.id}>`; + } else { + // Matrix bot emoji + return `<:_:1317980771348385822> \`${u.id}\``; + } + }) + .join(" ")}` + ); + } + + await interaction.reply({ + content: lines.join("\n"), + ephemeral: true, + }); + } + async handleMessageCreate(message: Message) { if (message.author.id === this.client.user!.id) return; // ignore bot's own messages if (message.webhookId) return; // if it's a webhook we don't care about it @@ -512,6 +1032,146 @@ class Discord_ { } } + async emoji_matrixToDiscord(mxc: MXC) { + let _parts = mxc.replace("mxc://", ""); + let host = _parts.split("/")[0]; + let attachmentId = _parts.slice(_parts.indexOf("/") + 1); + + if (host !== process.env.HOST) return null; + + const parts = Buffer.from(attachmentId, "base64url").toString().split(","); + const type = parts.shift(); + + if (type !== "emoji") return null; + + let [emojiid] = parts; + let isAnimated = emojiid[0] === "a"; + if (isAnimated) emojiid = emojiid.slice(1); + + return this.client.emojis.resolve(emojiid); + } + + /** + * Ensure the reactions stored in the db are represented on the Discord side + * @param guild_id + * @param channel_id + * @param message_id + */ + async syncReactions( + guild_id: string, + channel_id: string, + message_id: string + ) { + // get all reactions we need to represent + const storedReactions = await prisma.matrixReactions.findMany({ + where: { + OR: [ + { + from_discord: { + discord_guild_id: guild_id, + discord_channel_id: channel_id, + discord_id: message_id, + }, + }, + { + from_matrix: { + discord_guild_id: guild_id, + discord_channel_id: channel_id, + discord_id: message_id, + }, + }, + ], + }, + }); + + const guild = await Discord.client.guilds.fetch(guild_id); + const channel = await guild.channels.fetch(channel_id); + if (!channel?.isTextBased()) return; // this shouldn't be the case anyway + + const message = await channel.messages.fetch(message_id); + const represented = new Set(); + + for (const reaction of message.reactions.cache.values()) { + if (reaction.me) { + represented.add(reaction.emoji); + } + } + + // stored, but not represented (aka to represent) + const toAdd = new Set( + storedReactions + .filter( + (stored) => + ![...represented.values()].find( + (rep) => (rep.name || rep.identifier) === stored.reaction + ) + ) + .map(({ reaction }) => reaction) + ); + // represented, but not stored (aka to remove) + const toRemove = new Set( + [...represented.values()] + .filter( + (rep) => + !storedReactions.find( + (stored) => + stored.reaction === (rep.name || rep.identifier) || + (rep.id && + stored.reaction === + this.getBridgeProxy("emoji", rep.animated || false, rep.id)) + ) + ) + .map((rep) => { + if (rep.id) { + // is a custom emoji + return "custom:" + (rep.animated ? "a" : "") + rep.id; + } else { + // is a standard emoji + return rep.name!; + } + }) + ); + + for (let toBeAdded of toAdd) { + if (toBeAdded.startsWith("mxc")) { + const emoji = await this.emoji_matrixToDiscord(toBeAdded as any); + if (!emoji) continue; + + toBeAdded = emoji.id; + } + + await message.react(toBeAdded); + } + + for (let toBeRemoved of toRemove) { + if (toBeRemoved.startsWith("custom:")) { + let emojiid = toBeRemoved.replace("custom:", ""); + let isAnimated = emojiid[0] === "a"; + if (isAnimated) emojiid = emojiid.slice(1); + + toBeRemoved = emojiid; + } + + await message.reactions + .resolve(toBeRemoved) + ?.users.remove(this.client.user!.id); + } + } + + async reactToMessage( + guild_id: string, + channel_id: string, + message_id: string, + reaction: EmojiIdentifierResolvable + ) { + const guild = await Discord.client.guilds.fetch(guild_id); + const channel = await guild.channels.fetch(channel_id); + if (channel?.isTextBased()) { + const message = await channel.messages.fetch(message_id); + await message.react(reaction); + } + } + async sendMessage( guild_id: string, channel_id: string, diff --git a/src/lib/matrix.ts b/src/lib/matrix.ts index b8972fa..ce98b07 100644 --- a/src/lib/matrix.ts +++ b/src/lib/matrix.ts @@ -3,6 +3,7 @@ import { IClientEvent, IMatrixUser, MXC, + MatrixReactionEvent, MatrixRoomMembership, MatrixRoomMessage, SentClientEvent, @@ -41,6 +42,10 @@ export class Matrix { this.refresh_token = refresh_token; } + fqUsername() { + return "@" + this.username + ":" + MATRIX_HOMESERVER; + } + private fetch: (typeof Matrix)["fetch"] = (_endpoint, ...args) => { const [endpoint, rawParams] = _endpoint.split("?"); @@ -224,6 +229,29 @@ export class Matrix { } } + async getMessageReactions(roomId: string, eventId: string) { + return await this.fetch<{ chunk: MatrixReactionEvent[] }>( + `/_matrix/client/v1/rooms/${roomId}/relations/${eventId}/m.annotation` + ); + } + + async addReaction(roomId: string, eventId: string, key: string) { + return await this.sendEvent( + roomId, + { + type: "m.reaction", + content: { + "m.relates_to": { + rel_type: "m.annotation", + event_id: eventId, + key, + }, + }, + }, + Math.floor(Math.random() * 1000) + "" + ); + } + async syncProfile(displayname?: string, avatar_url?: string) { const fqun = `@${this.username}:${MATRIX_HOMESERVER}`; const profile = await Matrix.getUserProfile(fqun as any); @@ -500,6 +528,16 @@ export class Matrix { {} ); } + + static matrixLink( + room_id: string, + event_id?: string + ): `https://matrix.to/#/${string}` { + return (`https://matrix.to/#/${room_id}` + + (event_id ? "/" + event_id : "") + + "?via=" + + MATRIX_HOMESERVER) as any; + } } type IUsername = `_discord_${string}`; diff --git a/src/matrix.ts b/src/matrix.ts index 617c38b..c1ea8f1 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -7,6 +7,7 @@ import { MatrixEphemeralEvent, MatrixEphemeralTyping, MatrixError, + MatrixReactionEvent, MatrixRoomMembership, MatrixRoomMessage, MatrixRoomRedaction, @@ -49,13 +50,20 @@ class MatrixHandler_ { } for (const event of events) { - console.log("[Matrix->]", event); + if (event.type !== "m.reaction") { + console.log("[Matrix->]", event); + } if (process.env.VOID_ALL_TRANSACTIONS) { console.log("Voided transaction ^^"); continue; } + if ("redacted_because" in event) { + console.log("Received event with redacted_because", event); + return; + } + switch (event.type) { case "m.room.member": { const event2: MatrixRoomMembership = event as any; @@ -94,6 +102,13 @@ class MatrixHandler_ { await this.handleSticker(event2); break; } + case "m.reaction": { + if (event.sender.startsWith("@_discord")) continue; + const event2: MatrixReactionEvent = event as any; + + await this.handleReaction(event2); + break; + } default: { console.warn("Unhandled event (unknown type)", event); @@ -134,6 +149,66 @@ class MatrixHandler_ { return { status: 200 }; } + async handleReaction(event: MatrixReactionEvent) { + { + // Discord -> Matrix messages + + const messages = await prisma.discordMessages.findMany({ + where: { + matrix_event_id: event.content["m.relates_to"].event_id, + }, + }); + + for (const message of messages) { + await prisma.matrixReactions.create({ + data: { + matrix_event_id: event.event_id, + matrix_room_id: event.room_id, + matrix_author_id: event.sender, + reaction: event.content["m.relates_to"].key, + from_discord: { + connect: message, + }, + }, + }); + await Discord.syncReactions( + message.discord_guild_id, + message.discord_channel_id, + message.discord_id + ); + } + } + + { + // Matrix -> Discord messages + + const messages = await prisma.matrixMessages.findMany({ + where: { + matrix_event_id: event.content["m.relates_to"].event_id, + }, + }); + + for (const message of messages) { + await prisma.matrixReactions.create({ + data: { + matrix_event_id: event.event_id, + matrix_room_id: event.room_id, + matrix_author_id: event.sender, + reaction: event.content["m.relates_to"].key, + from_matrix: { + connect: message, + }, + }, + }); + await Discord.syncReactions( + message.discord_guild_id, + message.discord_channel_id, + message.discord_id + ); + } + } + } + async handleSticker(event: MatrixStickerEvent) { const bridges = await Glue.getBridges({ matrix_room: event.room_id }); @@ -486,6 +561,31 @@ class MatrixHandler_ { console.error("Failed to delete message", db_message, e); } } + + const reactions = await prisma.matrixReactions.findMany({ + where: { + matrix_event_id: event.content.redacts, + }, + include: { + from_discord: true, + }, + }); + + for (const reaction of reactions) { + await prisma.matrixReactions.delete({ + where: { + id: reaction.id, + }, + }); + + if (reaction.from_discord) { + await Discord.syncReactions( + reaction.from_discord.discord_guild_id, + reaction.from_discord.discord_channel_id, + reaction.from_discord.discord_id + ); + } + } } } diff --git a/src/types/env.ts b/src/types/env.ts index a00f739..8d58873 100644 --- a/src/types/env.ts +++ b/src/types/env.ts @@ -46,6 +46,7 @@ declare global { * @example `localhost` when MATRIX_HOMESERVER is `localhost:8008` */ MATRIX_HOMESERVER_HOST?: string; + DISCORD_CLIENT_ID: string; DISCORD_TOKEN: string; /** diff --git a/src/types/matrix.ts b/src/types/matrix.ts index 57e79d8..1bdee99 100644 --- a/src/types/matrix.ts +++ b/src/types/matrix.ts @@ -24,6 +24,14 @@ export interface IClientEvent { unsigned?: MatrixUnsignedData; } +export type MatrixReactionEvent = { type: "m.reaction" } & IClientEvent<{ + "m.relates_to": { + event_id: string; + key: string; + rel_type: "m.annotation"; + }; +}>; + export type MatrixStickerEvent = { type: "m.sticker" } & IClientEvent<{ body: string; info: ImageInfo & { diff --git a/tools/register-interactions.ts b/tools/register-interactions.ts new file mode 100644 index 0000000..66e32d2 --- /dev/null +++ b/tools/register-interactions.ts @@ -0,0 +1,41 @@ +import { + ApplicationCommandType, + ContextMenuCommandBuilder, + REST, + Routes, +} from "discord.js"; + +const GUILD_ID = "1139660377370665102"; +const CLIENT_ID = process.env.DISCORD_CLIENT_ID; + +const rest = new REST().setToken(process.env.DISCORD_TOKEN); + +const commands = [ + new ContextMenuCommandBuilder() + .setName("view_reactions") + .setNameLocalization("en-US", "View Reactions") + .setNameLocalization("en-GB", "View Reactions") + .setType(ApplicationCommandType.Message), + new ContextMenuCommandBuilder() + .setName("view_status") + .setNameLocalization("en-US", "View Bridge Status") + .setNameLocalization("en-GB", "View Bridge Status") + .setType(ApplicationCommandType.Message), +]; + +(async () => { + try { + console.log(`Started refresh`); + + // The put method is used to fully refresh all commands in the guild with the current set + const data = await rest.put( + Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID), + { body: commands } + ); + + console.log(`Successfully refreshed`); + } catch (error) { + // And of course, make sure you catch and log any errors! + console.error(error); + } +})(); -- GitLab