diff --git a/d2m/actions/add-reaction.js b/d2m/actions/add-reaction.js index 49afca9..b46af59 100644 --- a/d2m/actions/add-reaction.js +++ b/d2m/actions/add-reaction.js @@ -22,7 +22,7 @@ async function addReaction(data) { assert.equal(typeof parentID, "string") const roomID = await createRoom.ensureRoom(data.channel_id) const senderMxid = await registerUser.ensureSimJoined(user, roomID) - const eventID = api.sendEvent(roomID, "m.reaction", { + const eventID = await api.sendEvent(roomID, "m.reaction", { "m.relates_to": { rel_type: "m.annotation", event_id: parentID, diff --git a/d2m/actions/create-room.js b/d2m/actions/create-room.js index 0fd0646..484470e 100644 --- a/d2m/actions/create-room.js +++ b/d2m/actions/create-room.js @@ -21,8 +21,8 @@ async function roomToKState(roomID) { } /** - * @params {string} roomID - * @params {any} kstate + * @param {string} roomID + * @param {any} kstate */ function applyKStateDiffToRoom(roomID, kstate) { const events = ks.kstateToState(kstate) @@ -51,7 +51,7 @@ function convertNameAndTopic(channel, guild, customName) { } /** - * @param {DiscordTypes.APIGuildTextChannel} channel + * @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel * @param {DiscordTypes.APIGuild} guild */ async function channelToKState(channel, guild) { @@ -98,21 +98,27 @@ async function channelToKState(channel, guild) { * @returns {Promise} room ID */ async function createRoom(channel, guild, spaceID, kstate) { + const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, null) const roomID = await api.createRoom({ - name: channel.name, - topic: channel.topic || undefined, + name: convertedName, + topic: convertedTopic, preset: "private_chat", visibility: "private", invite: ["@cadence:cadence.moe"], // TODO initial_state: ks.kstateToState(kstate) }) - db.prepare("INSERT INTO channel_room (channel_id, room_id) VALUES (?, ?)").run(channel.id, roomID) + let threadParent = null + if (channel.type === DiscordTypes.ChannelType.PublicThread) { + /** @type {DiscordTypes.APIThreadChannel} */ // @ts-ignore + const thread = channel + threadParent = thread.parent_id + } + + db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES (?, ?, ?, NULL, ?)").run(channel.id, roomID, channel.name, threadParent) // Put the newly created child into the space - await api.sendState(spaceID, "m.space.child", roomID, { // TODO: should I deduplicate with the equivalent code from syncRoom? - via: ["cadence.moe"] // TODO: use the proper server - }) + _syncSpaceMember(channel, spaceID, roomID) return roomID } @@ -156,14 +162,15 @@ async function _syncRoom(channelID, shouldActuallySync) { assert.ok(channel) const guild = channelToGuild(channel) - /** @type {string?} */ - const existing = db.prepare("SELECT room_id from channel_room WHERE channel_id = ?").pluck().get(channel.id) + /** @type {{room_id: string, thread_parent: string?}} */ + const existing = db.prepare("SELECT room_id, thread_parent from channel_room WHERE channel_id = ?").get(channelID) + if (!existing) { const {spaceID, channelKState} = await channelToKState(channel, guild) return createRoom(channel, guild, spaceID, channelKState) } else { if (!shouldActuallySync) { - return existing // only need to ensure room exists, and it does. return the room ID + return existing.room_id // only need to ensure room exists, and it does. return the room ID } console.log(`[room sync] to matrix: ${channel.name}`) @@ -171,24 +178,68 @@ async function _syncRoom(channelID, shouldActuallySync) { const {spaceID, channelKState} = await channelToKState(channel, guild) // sync channel state to room - const roomKState = await roomToKState(existing) + const roomKState = await roomToKState(existing.room_id) const roomDiff = ks.diffKState(roomKState, channelKState) - const roomApply = applyKStateDiffToRoom(existing, roomDiff) + const roomApply = applyKStateDiffToRoom(existing.room_id, roomDiff) // sync room as space member - const spaceKState = await roomToKState(spaceID) - const spaceDiff = ks.diffKState(spaceKState, { - [`m.space.child/${existing}`]: { - via: ["cadence.moe"] // TODO: use the proper server - } - }) - const spaceApply = applyKStateDiffToRoom(spaceID, spaceDiff) + const spaceApply = _syncSpaceMember(channel, spaceID, existing.room_id) await Promise.all([roomApply, spaceApply]) - return existing + return existing.room_id } } +async function _unbridgeRoom(channelID) { + /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ + const channel = discord.channels.get(channelID) + assert.ok(channel) + const roomID = db.prepare("SELECT room_id from channel_room WHERE channel_id = ?").pluck().get(channelID) + assert.ok(roomID) + const spaceID = db.prepare("SELECT space_id FROM guild_space WHERE guild_id = ?").pluck().get(channel.guild_id) + assert.ok(spaceID) + + // remove room from being a space member + await api.sendState(spaceID, "m.space.child", roomID, {}) + + // send a notification in the room + await api.sendEvent(roomID, "m.room.message", { + msgtype: "m.notice", + body: "⚠️ This room was removed from the bridge." + }) + + // leave room + await api.leaveRoom(roomID) + + // delete room from database + const {changes} = db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channelID) + assert.equal(changes, 1) +} + + +/** + * @param {DiscordTypes.APIGuildTextChannel} channel + * @param {string} spaceID + * @param {string} roomID + * @returns {Promise} + */ +async function _syncSpaceMember(channel, spaceID, roomID) { + const spaceKState = await roomToKState(spaceID) + let spaceEventContent = {} + if ( + channel.type !== DiscordTypes.ChannelType.PrivateThread // private threads do not belong in the space (don't offer people something they can't join) + || channel["thread_metadata"]?.archived // archived threads do not belong in the space (don't offer people conversations that are no longer relevant) + ) { + spaceEventContent = { + via: ["cadence.moe"] // TODO: use the proper server + } + } + const spaceDiff = ks.diffKState(spaceKState, { + [`m.space.child/${roomID}`]: spaceEventContent + }) + return applyKStateDiffToRoom(spaceID, spaceDiff) +} + function ensureRoom(channelID) { return _syncRoom(channelID, false) } @@ -201,8 +252,11 @@ async function createAllForGuild(guildID) { const channelIDs = discord.guildChannelMap.get(guildID) assert.ok(channelIDs) for (const channelID of channelIDs) { - if (discord.channels.get(channelID)?.type === DiscordTypes.ChannelType.GuildText) { // TODO: guild sync thread channels and such. maybe make a helper function to check if a given channel is syncable? - await syncRoom(channelID).then(r => console.log(`synced ${channelID}:`, r)) + const allowedTypes = [DiscordTypes.ChannelType.GuildText, DiscordTypes.ChannelType.PublicThread] + // @ts-ignore + if (allowedTypes.includes(discord.channels.get(channelID)?.type)) { + const roomID = await syncRoom(channelID) + console.log(`synced ${channelID} <-> ${roomID}`) } } } @@ -213,3 +267,4 @@ module.exports.syncRoom = syncRoom module.exports.createAllForGuild = createAllForGuild module.exports.channelToKState = channelToKState module.exports._convertNameAndTopic = convertNameAndTopic +module.exports._unbridgeRoom = _unbridgeRoom diff --git a/d2m/actions/create-space.js b/d2m/actions/create-space.js index e3b6da7..02c2dcf 100644 --- a/d2m/actions/create-space.js +++ b/d2m/actions/create-space.js @@ -1,5 +1,6 @@ // @ts-check +const assert = require("assert") const passthrough = require("../../passthrough") const { sync, db } = passthrough /** @type {import("../../matrix/api")} */ @@ -9,13 +10,14 @@ const api = sync.require("../../matrix/api") * @param {import("discord-api-types/v10").RESTGetAPIGuildResult} guild */ async function createSpace(guild) { + assert(guild.name) const roomID = await api.createRoom({ name: guild.name, - preset: "private_chat", + preset: "private_chat", // cannot join space unless invited visibility: "private", power_level_content_override: { - events_default: 100, - invite: 50 + events_default: 100, // space can only be managed by bridge + invite: 0 // any existing member can invite others }, invite: ["@cadence:cadence.moe"], // TODO topic: guild.description || undefined, @@ -27,13 +29,13 @@ async function createSpace(guild) { type: "m.room.guest_access", state_key: "", content: { - guest_access: "can_join" + guest_access: "can_join" // guests can join space if other conditions are met } }, { type: "m.room.history_visibility", content: { - history_visibility: "invited" + history_visibility: "invited" // any events sent after user was invited are visible } } ] diff --git a/d2m/actions/edit-message.js b/d2m/actions/edit-message.js index 8e8c838..fa152cf 100644 --- a/d2m/actions/edit-message.js +++ b/d2m/actions/edit-message.js @@ -12,10 +12,7 @@ const api = sync.require("../../matrix/api") * @param {import("discord-api-types/v10").APIGuild} guild */ async function editMessage(message, guild) { - console.log(`*** applying edit for message ${message.id} in channel ${message.channel_id}`) const {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid} = await editToChanges.editToChanges(message, guild, api) - console.log("making these changes:") - console.dir({eventsToRedact, eventsToReplace, eventsToSend}, {depth: null}) // 1. Replace all the things. for (const {oldID, newContent} of eventsToReplace) { diff --git a/d2m/actions/register-user.js b/d2m/actions/register-user.js index 19c3a6d..a33cecc 100644 --- a/d2m/actions/register-user.js +++ b/d2m/actions/register-user.js @@ -85,7 +85,8 @@ async function ensureSimJoined(user, roomID) { */ async function memberToStateContent(user, member, guildID) { let displayname = user.username - if (member.nick && member.nick !== displayname) displayname = member.nick + " | " + displayname // prepend nick if present + // if (member.nick && member.nick !== displayname) displayname = member.nick + " | " + displayname // prepend nick if present + if (member.nick) displayname = member.nick const content = { displayname, diff --git a/d2m/actions/register-user.test.js b/d2m/actions/register-user.test.js index 0afce50..34470ba 100644 --- a/d2m/actions/register-user.test.js +++ b/d2m/actions/register-user.test.js @@ -8,7 +8,7 @@ test("member2state: general", async t => { await _memberToStateContent(testData.member.sheep.user, testData.member.sheep, testData.guild.general.id), { avatar_url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl", - displayname: "The Expert's Submarine | aprilsong", + displayname: "The Expert's Submarine", membership: "join", "moe.cadence.ooye.member": { avatar: "/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024" diff --git a/d2m/actions/send-message.js b/d2m/actions/send-message.js index 2132905..a5c8dac 100644 --- a/d2m/actions/send-message.js +++ b/d2m/actions/send-message.js @@ -22,8 +22,11 @@ async function sendMessage(message, guild) { let senderMxid = null if (!message.webhook_id) { - assert(message.member) - senderMxid = await registerUser.syncUser(message.author, message.member, message.guild_id, roomID) + if (message.member) { // available on a gateway message create event + senderMxid = await registerUser.syncUser(message.author, message.member, message.guild_id, roomID) + } else { // well, good enough... + senderMxid = await registerUser.ensureSimJoined(message.author, roomID) + } } const events = await messageToEvent.messageToEvent(message, guild, {}, {api}) @@ -35,7 +38,7 @@ async function sendMessage(message, guild) { const eventWithoutType = {...event} delete eventWithoutType.$type - const eventID = await api.sendEvent(roomID, eventType, event, senderMxid) + const eventID = await api.sendEvent(roomID, eventType, event, senderMxid, new Date(message.timestamp).getTime()) db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, channel_id, part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, event.msgtype || null, message.id, message.channel_id, eventPart) // source 1 = discord eventPart = 1 // TODO: use more intelligent algorithm to determine whether primary or supporting diff --git a/d2m/converters/message-to-event.embeds.test.js b/d2m/converters/message-to-event.embeds.test.js new file mode 100644 index 0000000..7972f13 --- /dev/null +++ b/d2m/converters/message-to-event.embeds.test.js @@ -0,0 +1,40 @@ +const {test} = require("supertape") +const {messageToEvent} = require("./message-to-event") +const data = require("../../test/data") +const Ty = require("../../types") + +/** + * @param {string} roomID + * @param {string} eventID + * @returns {(roomID: string, eventID: string) => Promise>} + */ +function mockGetEvent(t, roomID_in, eventID_in, outer) { + return async function(roomID, eventID) { + t.equal(roomID, roomID_in) + t.equal(eventID, eventID_in) + return new Promise(resolve => { + setTimeout(() => { + resolve({ + event_id: eventID_in, + room_id: roomID_in, + origin_server_ts: 1680000000000, + unsigned: { + age: 2245, + transaction_id: "$local.whatever" + }, + ...outer + }) + }) + }) + } +} + +test("message2event embeds: nothing but a field", async t => { + const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.mentions": {}, + msgtype: "m.text", + body: "Amanda" + }]) +}) diff --git a/d2m/converters/message-to-event.js b/d2m/converters/message-to-event.js index 29730d4..c34b389 100644 --- a/d2m/converters/message-to-event.js +++ b/d2m/converters/message-to-event.js @@ -27,11 +27,13 @@ function getDiscordParseCallbacks(message, useHTML) { }, /** @param {{id: string, type: "discordChannel"}} node */ channel: node => { - const {room_id, name, nick} = db.prepare("SELECT room_id, name, nick FROM channel_room WHERE channel_id = ?").get(node.id) - if (room_id && useHTML) { - return `#${nick || name}` + const row = db.prepare("SELECT room_id, name, nick FROM channel_room WHERE channel_id = ?").get(node.id) + if (!row) { + return `<#${node.id}>` // fallback for when this channel is not bridged + } else if (useHTML) { + return `#${row.nick || row.name}` } else { - return `#${nick || name}` + return `#${row.nick || row.name}` } }, /** @param {{animated: boolean, name: string, id: string, type: "discordEmoji"}} node */ @@ -108,6 +110,13 @@ async function messageToEvent(message, guild, options = {}, di) { addMention(repliedToEventSenderMxid) } + let msgtype = "m.text" + // Handle message type 4, channel name changed + if (message.type === DiscordTypes.MessageType.ChannelNameChange) { + msgtype = "m.emote" + message.content = "changed the channel name to **" + message.content + "**" + } + // Text content appears first if (message.content) { let content = message.content @@ -188,7 +197,7 @@ async function messageToEvent(message, guild, options = {}, di) { const newTextMessageEvent = { $type: "m.room.message", "m.mentions": mentions, - msgtype: "m.text", + msgtype, body: body } @@ -239,6 +248,22 @@ async function messageToEvent(message, guild, options = {}, di) { size: attachment.size } } + } else if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) { + return { + $type: "m.room.message", + "m.mentions": mentions, + msgtype: "m.video", + url: await file.uploadDiscordFileToMxc(attachment.url), + external_url: attachment.url, + body: attachment.description || attachment.filename, + filename: attachment.filename, + info: { + mimetype: attachment.content_type, + w: attachment.width, + h: attachment.height, + size: attachment.size + } + } } else { return { $type: "m.room.message", diff --git a/d2m/converters/message-to-event.test.js b/d2m/converters/message-to-event.test.js index 86942a7..260ecda 100644 --- a/d2m/converters/message-to-event.test.js +++ b/d2m/converters/message-to-event.test.js @@ -330,4 +330,14 @@ test("message2event: very large attachment is linked instead of being uploaded", }]) }) -// TODO: read "edits of replies" in the spec +test("message2event: type 4 channel name change", async t => { + const events = await messageToEvent(data.special_message.thread_name_change, data.guild.general) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.mentions": {}, + msgtype: "m.emote", + body: "changed the channel name to **worming**", + format: "org.matrix.custom.html", + formatted_body: "changed the channel name to worming" + }]) +}) diff --git a/d2m/converters/user-to-mxid.test.js b/d2m/converters/user-to-mxid.test.js index 8c4c430..1b31260 100644 --- a/d2m/converters/user-to-mxid.test.js +++ b/d2m/converters/user-to-mxid.test.js @@ -16,6 +16,10 @@ test("user2name: works on emojis", t => { t.equal(userToSimName({username: "🍪 Cookie Monster 🍪", discriminator: "0001"}), "cookie_monster") }) +test("user2name: works on single emoji at the end", t => { + t.equal(userToSimName({username: "Amanda 🎵", discriminator: "2192"}), "amanda") +}) + test("user2name: works on crazy name", t => { t.equal(userToSimName({username: "*** D3 &W (89) _7//-", discriminator: "0001"}), "d3_w_89__7//") }) @@ -34,4 +38,4 @@ test("user2name: uses ID if name becomes too short", t => { test("user2name: uses ID when name has only disallowed characters", t => { t.equal(userToSimName({username: "!@#$%^&*", discriminator: "0001", id: "9"}), "9") -}) \ No newline at end of file +}) diff --git a/d2m/discord-packets.js b/d2m/discord-packets.js index c0ba1a6..776d4b1 100644 --- a/d2m/discord-packets.js +++ b/d2m/discord-packets.js @@ -35,6 +35,21 @@ const utils = { arr.push(channel.id) client.channels.set(channel.id, channel) } + for (const thread of message.d.threads || []) { + // @ts-ignore + thread.guild_id = message.d.id + arr.push(thread.id) + client.channels.set(thread.id, thread) + } + eventDispatcher.checkMissedMessages(client, message.d) + + + } else if (message.t === "THREAD_CREATE") { + client.channels.set(message.d.id, message.d) + + + } else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") { + client.channels.set(message.d.id, message.d) } else if (message.t === "GUILD_DELETE") { @@ -67,10 +82,22 @@ const utils = { // Event dispatcher for OOYE bridge operations try { - if (message.t === "MESSAGE_CREATE") { + if (message.t === "CHANNEL_UPDATE") { + await eventDispatcher.onChannelOrThreadUpdate(client, message.d, false) + + } else if (message.t === "THREAD_CREATE") { + console.log(message) + // await eventDispatcher.onThreadCreate(client, message.d) + + } else if (message.t === "THREAD_UPDATE") { + await eventDispatcher.onChannelOrThreadUpdate(client, message.d, true) + + } else if (message.t === "MESSAGE_CREATE") { + console.log(message) await eventDispatcher.onMessageCreate(client, message.d) } else if (message.t === "MESSAGE_UPDATE") { + console.log(message) await eventDispatcher.onMessageUpdate(client, message.d) } else if (message.t === "MESSAGE_DELETE") { diff --git a/d2m/event-dispatcher.js b/d2m/event-dispatcher.js index 8e64591..154cb34 100644 --- a/d2m/event-dispatcher.js +++ b/d2m/event-dispatcher.js @@ -10,11 +10,17 @@ const editMessage = sync.require("./actions/edit-message") const deleteMessage = sync.require("./actions/delete-message") /** @type {import("./actions/add-reaction")}) */ const addReaction = sync.require("./actions/add-reaction") +/** @type {import("./actions/create-room")}) */ +const createRoom = sync.require("./actions/create-room") /** @type {import("../matrix/api")}) */ const api = sync.require("../matrix/api") 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 = { @@ -29,33 +35,94 @@ module.exports = { console.error(`while handling this ${gatewayMessage.t} gateway event:`) console.dir(gatewayMessage.d, {depth: null}) - if (Date.now() - lastReportedEvent > 5000) { - lastReportedEvent = Date.now() - const channelID = gatewayMessage.d.channel_id - if (channelID) { - const roomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").pluck().get(channelID) - 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) + 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] } - 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}` - + `
${stackLines.join("\n")}
` - + `
Original payload` - + `
${util.inspect(gatewayMessage.d, false, 4, false)}
`, - "m.mentions": { - user_ids: ["@cadence:cadence.moe"] - } - }) + await module.exports.onMessageCreate(client, simulatedGatewayDispatchData) } } }, + /** + * @param {import("./discord-client")} client + * @param {import("discord-api-types/v10").APIChannel} thread + */ + async onThreadCreate(client, thread) { + console.log(thread) + const parentRoomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").get(thread.parent_id) + if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel + await createRoom.syncRoom(thread.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 @@ -72,13 +139,13 @@ module.exports = { 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 (message.guild_id !== "112760669178241024" && message.guild_id !== "497159726455455754") return // TODO: activate on other servers (requires the space creation flow to be done first) + if (!isGuildAllowed(guild.id)) return await sendMessage.sendMessage(message, guild) }, /** * @param {import("./discord-client")} client - * @param {import("discord-api-types/v10").GatewayMessageUpdateDispatchData} message + * @param {import("discord-api-types/v10").GatewayMessageUpdateDispatchData} data */ async onMessageUpdate(client, data) { if (data.webhook_id) { @@ -97,7 +164,7 @@ module.exports = { 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 (message.guild_id !== "112760669178241024" && message.guild_id !== "497159726455455754") return // TODO: activate on other servers (requires the space creation flow to be done first) + if (!isGuildAllowed(guild.id)) return await editMessage.editMessage(message, guild) } }, @@ -109,7 +176,6 @@ module.exports = { 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. if (data.emoji.id !== null) return // TODO: image emoji reactions - console.log(data) await addReaction.addReaction(data) }, @@ -118,7 +184,6 @@ module.exports = { * @param {import("discord-api-types/v10").GatewayMessageDeleteDispatchData} data */ async onMessageDelete(client, data) { - console.log(data) await deleteMessage.deleteMessage(data) } } diff --git a/db/data-for-test.sql b/db/data-for-test.sql index fa04562..ee31fe3 100644 --- a/db/data-for-test.sql +++ b/db/data-for-test.sql @@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS "channel_room" ( "room_id" TEXT NOT NULL UNIQUE, "name" TEXT, "nick" TEXT, + "thread_parent" TEXT, PRIMARY KEY("channel_id") ); CREATE TABLE IF NOT EXISTS "event_message" ( @@ -54,10 +55,10 @@ BEGIN TRANSACTION; INSERT INTO guild_space (guild_id, space_id) VALUES ('112760669178241024', '!jjWAGMeQdNrVZSSfvz:cadence.moe'); -INSERT INTO channel_room (channel_id, room_id, name, nick) VALUES -('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main'), -('497161350934560778', '!edUxjVdzgUvXDUIQCK:cadence.moe', 'amanda-spam', NULL), -('160197704226439168', '!uCtjHhfGlYbVnPVlkG:cadence.moe', 'the-stanley-parable-channel', 'bots'); +INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES +('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL), +('497161350934560778', '!edUxjVdzgUvXDUIQCK:cadence.moe', 'amanda-spam', NULL, NULL), +('160197704226439168', '!uCtjHhfGlYbVnPVlkG:cadence.moe', 'the-stanley-parable-channel', 'bots', NULL); INSERT INTO sim (discord_id, sim_name, localpart, mxid) VALUES ('0', 'bot', '_ooye_bot', '@_ooye_bot:cadence.moe'), diff --git a/m2d/actions/add-reaction.js b/m2d/actions/add-reaction.js index d259ddb..68828dd 100644 --- a/m2d/actions/add-reaction.js +++ b/m2d/actions/add-reaction.js @@ -18,10 +18,12 @@ async function addReaction(event) { // no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it let emoji = event.content["m.relates_to"].key // TODO: handle custom text or emoji reactions - emoji = encodeURIComponent(emoji) - emoji = emoji.replace(/%EF%B8%8F/g, "") + let encoded = encodeURIComponent(emoji) + let encodedTrimmed = encoded.replace(/%EF%B8%8F/g, "") - return discord.snow.channel.createReaction(channelID, messageID, emoji) + console.log("add reaction from matrix:", emoji, encoded, encodedTrimmed) + + return discord.snow.channel.createReaction(channelID, messageID, encoded) } module.exports.addReaction = addReaction diff --git a/m2d/actions/channel-webhook.js b/m2d/actions/channel-webhook.js index b62057b..6d39da7 100644 --- a/m2d/actions/channel-webhook.js +++ b/m2d/actions/channel-webhook.js @@ -41,19 +41,19 @@ async function ensureWebhook(channelID, forceCreate = false) { async function withWebhook(channelID, callback) { const webhook = await ensureWebhook(channelID, false) return callback(webhook).catch(e => { - console.error(e) // TODO: check if the error was webhook-related and if webhook.created === false, then: const webhook = ensureWebhook(channelID, true); return callback(webhook) - throw new Error(e) + throw e }) } /** * @param {string} channelID * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer}[]}} data + * @param {string} [threadID] */ -async function sendMessageWithWebhook(channelID, data) { +async function sendMessageWithWebhook(channelID, data, threadID) { const result = await withWebhook(channelID, async webhook => { - return discord.snow.webhook.executeWebhook(webhook.id, webhook.token, data, {wait: true, disableEveryone: true}) + return discord.snow.webhook.executeWebhook(webhook.id, webhook.token, data, {wait: true, thread_id: threadID, disableEveryone: true}) }) return result } diff --git a/m2d/actions/send-event.js b/m2d/actions/send-event.js index 3f49fa4..88ba0fd 100644 --- a/m2d/actions/send-event.js +++ b/m2d/actions/send-event.js @@ -13,7 +13,13 @@ const eventToMessage = sync.require("../converters/event-to-message") /** @param {import("../../types").Event.Outer} event */ async function sendEvent(event) { // TODO: we just assume the bridge has already been created - const channelID = db.prepare("SELECT channel_id FROM channel_room WHERE room_id = ?").pluck().get(event.room_id) + const row = db.prepare("SELECT channel_id, thread_parent FROM channel_room WHERE room_id = ?").get(event.room_id) + let channelID = row.channel_id + let threadID = undefined + if (row.thread_parent) { + threadID = channelID + channelID = row.thread_parent // it's the thread's parent... get with the times... + } // no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it @@ -24,7 +30,7 @@ async function sendEvent(event) { const messageResponses = [] let eventPart = 0 // 0 is primary, 1 is supporting for (const message of messages) { - const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message) + const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID) db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, channel_id, part, source) VALUES (?, ?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content.msgtype || null, messageResponse.id, channelID, eventPart) // source 0 = matrix eventPart = 1 // TODO: use more intelligent algorithm to determine whether primary or supporting? diff --git a/m2d/converters/event-to-message.js b/m2d/converters/event-to-message.js index 817ffff..b2c56a9 100644 --- a/m2d/converters/event-to-message.js +++ b/m2d/converters/event-to-message.js @@ -16,11 +16,25 @@ function eventToMessage(event) { /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer}[]})[]} */ const messages = [] + let displayName = event.sender + let avatarURL = undefined + const match = event.sender.match(/^@(.*?):/) + if (match) { + displayName = match[1] + // TODO: get the media repo domain and the avatar url from the matrix member event + } + if (event.content.msgtype === "m.text") { messages.push({ content: event.content.body, - username: event.sender.replace(/^@/, ""), - avatar_url: undefined, // TODO: provide the URL to the avatar from the homeserver's content repo + username: displayName, + avatar_url: avatarURL + }) + } else if (event.content.msgtype === "m.emote") { + messages.push({ + content: `*${displayName} ${event.content.body}*`, + username: displayName, + avatar_url: avatarURL }) } diff --git a/m2d/converters/event-to-message.test.js b/m2d/converters/event-to-message.test.js index e687059..f0c4664 100644 --- a/m2d/converters/event-to-message.test.js +++ b/m2d/converters/event-to-message.test.js @@ -21,7 +21,7 @@ test("event2message: janky test", t => { } }), [{ - username: "cadence:cadence.moe", + username: "cadence", content: "test", avatar_url: undefined }] diff --git a/m2d/event-dispatcher.js b/m2d/event-dispatcher.js index 82ebd75..44eba85 100644 --- a/m2d/event-dispatcher.js +++ b/m2d/event-dispatcher.js @@ -1,9 +1,10 @@ // @ts-check -/** +/* * Grab Matrix events we care about, check them, and bridge them. */ +const util = require("util") const Ty = require("../types") const {sync, as} = require("../passthrough") @@ -13,21 +14,58 @@ const sendEvent = sync.require("./actions/send-event") const addReaction = sync.require("./actions/add-reaction") /** @type {import("./converters/utils")} */ const utils = sync.require("./converters/utils") +/** @type {import("../matrix/api")}) */ +const api = sync.require("../matrix/api") -sync.addTemporaryListener(as, "type:m.room.message", +let lastReportedEvent = 0 + +function guard(type, fn) { + return async function(event, ...args) { + try { + return await fn(event, ...args) + } catch (e) { + 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? + console.error(`while handling this ${type} gateway event:`) + console.dir(event, {depth: null}) + + if (Date.now() - lastReportedEvent < 5000) return + lastReportedEvent = Date.now() + + let stackLines = e.stack.split("\n") + api.sendEvent(event.room_id, "m.room.message", { + msgtype: "m.text", + body: "\u26a0 Matrix event not delivered to Discord. See formatted content for full details.", + format: "org.matrix.custom.html", + formatted_body: "\u26a0 Matrix event not delivered to Discord" + + `
Event type: ${type}` + + `
${e.toString()}` + + `
Error trace` + + `
${stackLines.join("\n")}
` + + `
Original payload` + + `
${util.inspect(event, false, 4, false)}
`, + "m.mentions": { + user_ids: ["@cadence:cadence.moe"] + } + }) + } + } +} + +sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", /** * @param {Ty.Event.Outer} event it is a m.room.message because that's what this listener is filtering for */ async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return const messageResponses = await sendEvent.sendEvent(event) -}) +})) -sync.addTemporaryListener(as, "type:m.reaction", +sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction", /** * @param {Ty.Event.Outer} event it is a m.reaction because that's what this listener is filtering for */ async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return await addReaction.addReaction(event) -}) +})) diff --git a/notes.md b/notes.md index ec2b9bb..dbc5ecd 100644 --- a/notes.md +++ b/notes.md @@ -9,6 +9,23 @@ A database will be used to store the discord id to matrix event id mapping. Tabl There needs to be a way to easily manually trigger something later. For example, it should be easy to manually retry sending a message, or check all members for changes, etc. +## Discord's gateway when a new thread is created from an existing message: + +1. Regular MESSAGE_CREATE of the message that it's going to branch off in the future. Example ID -6423 +2. It MESSAGE_UPDATEd the ID -6423 with this whole data: {id:-6423,flags: 32,channel_id:-2084,guild_id:-1727} (ID is the message ID it's branching off, channel ID is the parent channel containing the message ID it's branching off) +3. It THREAD_CREATEd and gave us a channel object with type 11 (public thread) and parent ID -2084 and ID -6423. +4. It MESSAGE_CREATEd type 21 with blank content and a message reference pointing towards channel -2084 message -6423. (That's the message it branched from in the parent channel.) This MESSAGE_CREATE got ID -4631 (a new ID). Apart from that it's a regular message object. +5. Finally, as the first "real" message in that thread (which a user must send to create that thread!) it sent a regular message object with a new message ID and a channel ID of -6423. + +When viewing this thread, it shows the message branched from at the top, and then the first "real" message right underneath, as separate groups. + +## Current manual process for setting up a server + +1. Call createSpace.createSpace(discord.guilds.get(GUILD_ID)) +2. Call createRoom.createAllForGuild(GUILD_ID) +3. Edit source code of event-dispatcher.js isGuildAllowed() and add the guild ID to the list +4. If developing, make sure SSH port forward is activated, then wait for events to sync over! + ## Transforming content 1. Upload attachments to mxc if they are small enough. diff --git a/stdin.js b/stdin.js index 7e0db89..61a2a08 100644 --- a/stdin.js +++ b/stdin.js @@ -13,6 +13,7 @@ const registerUser = sync.require("./d2m/actions/register-user") const mreq = sync.require("./matrix/mreq") const api = sync.require("./matrix/api") const sendEvent = sync.require("./m2d/actions/send-event") +const eventDispatcher = sync.require("./d2m/event-dispatcher") const guildID = "112760669178241024" const extraContext = {} diff --git a/test/data.js b/test/data.js index a1d3ece..fc8cbbd 100644 --- a/test/data.js +++ b/test/data.js @@ -816,6 +816,130 @@ module.exports = { format_type: 1, name: "pomu puff" }] + }, + message_in_thread: { + type: 0, + tts: false, + timestamp: "2023-08-19T01:55:02.063000+00:00", + referenced_message: null, + position: 942, + pinned: false, + nonce: "1142275498206822400", + mentions: [], + mention_roles: [], + mention_everyone: false, + member: { + roles: [ + "112767366235959296", "118924814567211009", + "204427286542417920", "199995902742626304", + "222168467627835392", "238028326281805825", + "259806643414499328", "265239342648131584", + "271173313575780353", "287733611912757249", + "225744901915148298", "305775031223320577", + "318243902521868288", "348651574924541953", + "349185088157777920", "378402925128712193", + "392141548932038658", "393912152173576203", + "482860581670486028", "495384759074160642", + "638988388740890635", "373336013109461013", + "530220455085473813", "454567553738473472", + "790724320824655873", "1123518980456452097", + "1040735082610167858", "695946570482450442", + "1123460940935991296", "849737964090556488" + ], + premium_since: null, + pending: false, + nick: null, + mute: false, + joined_at: "2015-11-11T09:55:40.321000+00:00", + flags: 0, + deaf: false, + communication_disabled_until: null, + avatar: null + }, + id: "1142275501721911467", + flags: 0, + embeds: [], + edited_timestamp: null, + content: "don't mind me, posting something for cadence", + components: [], + channel_id: "910283343378120754", + author: { + username: "kumaccino", + public_flags: 128, + id: "113340068197859328", + global_name: "kumaccino", + discriminator: "0", + avatar_decoration_data: null, + avatar: "b48302623a12bc7c59a71328f72ccb39" + }, + attachments: [], + guild_id: "112760669178241024" + } + }, + message_with_embeds: { + nothing_but_a_field: { + guild_id: "497159726455455754", + mentions: [], + id: "1141934888862351440", + type: 20, + content: "", + channel_id: "497161350934560778", + author: { + id: "1109360903096369153", + username: "Amanda 🎵", + avatar: "d56cd1b26e043ae512edae2214962faa", + discriminator: "2192", + public_flags: 524288, + flags: 524288, + bot: true, + banner: null, + accent_color: null, + global_name: null, + avatar_decoration_data: null, + banner_color: null + }, + attachments: [], + embeds: [ + { + type: "rich", + color: 3092790, + fields: [ + { + name: "Amanda 🎵#2192 <:online:606664341298872324>\nwillow tree, branch 0", + value: "**❯ Uptime:**\n3m 55s\n**❯ Memory:**\n64.45MB", + inline: false + } + ] + } + ], + mention_roles: [], + pinned: false, + mention_everyone: false, + tts: false, + timestamp: "2023-08-18T03:21:33.629000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + application_id: "1109360903096369153", + interaction: { + id: "1141934887608254475", + type: 2, + name: "stats", + user: { + id: "320067006521147393", + username: "papiophidian", + avatar: "47a19b0445069b826e136da4df4259bb", + discriminator: "0", + public_flags: 4194880, + flags: 4194880, + banner: null, + accent_color: null, + global_name: "PapiOphidian", + avatar_decoration_data: null, + banner_color: null + } + }, + webhook_id: "1109360903096369153" } }, message_update: { @@ -1219,5 +1343,38 @@ module.exports = { ], guild_id: "112760669178241024" } + }, + special_message: { + thread_name_change: { + id: "1142391602799710298", + type: 4, + content: "worming", + channel_id: "1142271000067706880", + author: { + id: "772659086046658620", + username: "cadence.worm", + avatar: "4b5c4b28051144e4c111f0113a0f1cf1", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "cadence", + avatar_decoration_data: null, + banner_color: null + }, + attachments: [], + embeds: [], + mentions: [], + mention_roles: [], + pinned: false, + mention_everyone: false, + tts: false, + timestamp: "2023-08-19T09:36:22.717000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + position: 12 + } } } diff --git a/test/test.js b/test/test.js index 31ab099..03394f0 100644 --- a/test/test.js +++ b/test/test.js @@ -21,6 +21,7 @@ require("../matrix/kstate.test") require("../matrix/api.test") require("../matrix/read-registration.test") require("../d2m/converters/message-to-event.test") +require("../d2m/converters/message-to-event.embeds.test") require("../d2m/converters/edit-to-changes.test") require("../d2m/actions/create-room.test") require("../d2m/converters/user-to-mxid.test")