const assert = require("assert").strict const util = require("util") const {sync, db} = require("../passthrough") /** @type {import("./actions/send-message")}) */ const sendMessage = sync.require("./actions/send-message") /** @type {import("./actions/edit-message")}) */ const editMessage = sync.require("./actions/edit-message") /** @type {import("./actions/delete-message")}) */ const deleteMessage = sync.require("./actions/delete-message") /** @type {import("./actions/add-reaction")}) */ const addReaction = sync.require("./actions/add-reaction") /** @type {import("./actions/announce-thread")}) */ const announceThread = sync.require("./actions/announce-thread") /** @type {import("./actions/create-room")}) */ const createRoom = sync.require("./actions/create-room") /** @type {import("./actions/create-space")}) */ const createSpace = sync.require("./actions/create-space") /** @type {import("../matrix/api")}) */ const api = sync.require("../matrix/api") /** @type {import("./discord-command-handler")}) */ const discordCommandHandler = sync.require("./discord-command-handler") let lastReportedEvent = 0 function isGuildAllowed(guildID) { return ["112760669178241024", "497159726455455754", "1100319549670301727"].includes(guildID) } // Grab Discord events we care about for the bridge, check them, and pass them on module.exports = { /** * @param {import("./discord-client")} client * @param {Error} e * @param {import("cloudstorm").IGatewayMessage} gatewayMessage */ onError(client, e, gatewayMessage) { console.error("hit event-dispatcher's error handler with this exception:") console.error(e) // TODO: also log errors into a file or into the database, maybe use a library for this? or just wing it? definitely need to be able to store the formatted event body to load back in later console.error(`while handling this ${gatewayMessage.t} gateway event:`) console.dir(gatewayMessage.d, {depth: null}) if (Date.now() - lastReportedEvent < 5000) return lastReportedEvent = Date.now() const channelID = gatewayMessage.d.channel_id if (!channelID) return const roomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").pluck().get(channelID) if (!roomID) return let stackLines = e.stack.split("\n") let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/")) if (cloudstormLine !== -1) { stackLines = stackLines.slice(0, cloudstormLine - 2) } api.sendEvent(roomID, "m.room.message", { msgtype: "m.text", body: "\u26a0 Bridged event from Discord not delivered. See formatted content for full details.", format: "org.matrix.custom.html", formatted_body: "\u26a0 Bridged event from Discord not delivered" + `
Gateway event: ${gatewayMessage.t}` + `
${e.toString()}` + `
Error trace` + `
${stackLines.join("\n")}
` + `
Original payload` + `
${util.inspect(gatewayMessage.d, false, 4, false)}
`, "m.mentions": { user_ids: ["@cadence:cadence.moe"] } }) }, /** * When logging back in, check if we missed any conversations in any channels. Bridge up to 49 missed messages per channel. * If more messages were missed, only the latest missed message will be posted. TODO: Consider bridging more, or post a warning when skipping history? * This can ONLY detect new messages, not any other kind of event. Any missed edits, deletes, reactions, etc will not be bridged. * @param {import("./discord-client")} client * @param {import("discord-api-types/v10").GatewayGuildCreateDispatchData} guild */ async checkMissedMessages(client, guild) { if (guild.unavailable) return const bridgedChannels = db.prepare("SELECT channel_id FROM channel_room").pluck().all() const prepared = db.prepare("SELECT message_id FROM event_message WHERE channel_id = ? AND message_id = ?").pluck() for (const channel of guild.channels.concat(guild.threads)) { if (!bridgedChannels.includes(channel.id)) continue if (!channel.last_message_id) continue const latestWasBridged = prepared.get(channel.id, channel.last_message_id) if (latestWasBridged) continue /** More recent messages come first. */ console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`) const messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 50}) let latestBridgedMessageIndex = messages.findIndex(m => { return prepared.get(channel.id, m.id) }) console.log(`[check missed messages] got ${messages.length} messages; last message that IS bridged is at position ${latestBridgedMessageIndex} in the channel`) if (latestBridgedMessageIndex === -1) latestBridgedMessageIndex = 1 // rather than crawling the ENTIRE channel history, let's just bridge the most recent 1 message to make it up to date. for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) { const simulatedGatewayDispatchData = { guild_id: guild.id, mentions: [], ...messages[i] } await module.exports.onMessageCreate(client, simulatedGatewayDispatchData) } } }, /** * Announces to the parent room that the thread room has been created. * See notes.md, "Ignore MESSAGE_UPDATE and bridge THREAD_CREATE as the announcement" * @param {import("./discord-client")} client * @param {import("discord-api-types/v10").APIThreadChannel} thread */ async onThreadCreate(client, thread) { const parentRoomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").pluck().get(thread.parent_id) if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread) await announceThread.announceThread(parentRoomID, threadRoomID, thread) }, /** * @param {import("./discord-client")} client * @param {import("discord-api-types/v10").GatewayGuildUpdateDispatchData} guild */ async onGuildUpdate(client, guild) { const spaceID = db.prepare("SELECT space_id FROM guild_space WHERE guild_id = ?").pluck().get(guild.id) if (!spaceID) return await createSpace.syncSpace(guild.id) }, /** * @param {import("./discord-client")} client * @param {import("discord-api-types/v10").GatewayChannelUpdateDispatchData} channelOrThread * @param {boolean} isThread */ async onChannelOrThreadUpdate(client, channelOrThread, isThread) { const roomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").get(channelOrThread.id) if (!roomID) return // No target room to update the data on await createRoom.syncRoom(channelOrThread.id) }, /** * @param {import("./discord-client")} client * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message */ async onMessageCreate(client, message) { if (message.webhook_id) { const row = db.prepare("SELECT webhook_id FROM webhook WHERE webhook_id = ?").pluck().get(message.webhook_id) if (row) { // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. return } } /** @type {import("discord-api-types/v10").APIGuildChannel} */ const channel = client.channels.get(message.channel_id) if (!channel.guild_id) return // Nothing we can do in direct messages. const guild = client.guilds.get(channel.guild_id) if (!isGuildAllowed(guild.id)) return await sendMessage.sendMessage(message, guild), await discordCommandHandler.execute(message, channel, guild) }, /** * @param {import("./discord-client")} client * @param {import("discord-api-types/v10").GatewayMessageUpdateDispatchData} data */ async onMessageUpdate(client, data) { if (data.webhook_id) { const row = db.prepare("SELECT webhook_id FROM webhook WHERE webhook_id = ?").pluck().get(data.webhook_id) if (row) { // The update was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. return } } // Based on looking at data they've sent me over the gateway, this is the best way to check for meaningful changes. // If the message content is a string then it includes all interesting fields and is meaningful. if (typeof data.content === "string") { /** @type {import("discord-api-types/v10").GatewayMessageCreateDispatchData} */ const message = data /** @type {import("discord-api-types/v10").APIGuildChannel} */ const channel = client.channels.get(message.channel_id) if (!channel.guild_id) return // Nothing we can do in direct messages. const guild = client.guilds.get(channel.guild_id) if (!isGuildAllowed(guild.id)) return await editMessage.editMessage(message, guild) } }, /** * @param {import("./discord-client")} client * @param {import("discord-api-types/v10").GatewayMessageReactionAddDispatchData} data */ async onReactionAdd(client, data) { if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix. discordCommandHandler.onReactionAdd(data) if (data.emoji.id !== null) return // TODO: image emoji reactions await addReaction.addReaction(data) }, /** * @param {import("./discord-client")} client * @param {import("discord-api-types/v10").GatewayMessageDeleteDispatchData} data */ async onMessageDelete(client, data) { await deleteMessage.deleteMessage(data) } }