From 1758b7aa2286699ee09b59ec389ab0b8a1e43071 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 26 Nov 2025 17:21:15 +1300 Subject: [PATCH 1/5] m->d: make image-replies work --- src/m2d/converters/event-to-message.js | 294 ++++++++++---------- src/m2d/converters/event-to-message.test.js | 93 +++++++ 2 files changed, 241 insertions(+), 146 deletions(-) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index fd9289d..eca3008 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -605,7 +605,7 @@ async function eventToMessage(event, guild, di) { } attachments.push({id: "0", filename}) pendingFiles.push({name: filename, mxc: event.content.url}) - } else if (shouldProcessTextEvent) { + } else { // Handling edits. If the edit was an edit of a reply, edits do not include the reply reference, so we need to fetch up to 2 more events. // this event ---is an edit of--> original event ---is a reply to--> past event await (async () => { @@ -738,157 +738,159 @@ async function eventToMessage(event, guild, di) { replyLine = `-# > ${replyLine}${contentPreview}\n` })() - if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) { - let input = event.content.formatted_body - if (event.content.msgtype === "m.emote") { - input = `* ${displayName} ${input}` - } - - // Handling mentions of Discord users - input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => { - mxid = decodeURIComponent(mxid) - if (mxUtils.eventSenderIsFromDiscord(mxid)) { - // Handle mention of an OOYE sim user by their mxid - const id = select("sim", "user_id", {mxid}).pluck().get() - if (!id) return whole - return `${attributeValue} data-user-id="${id}">` - } else { - // Handle mention of a Matrix user by their mxid - // Check if this Matrix user is actually the sim user from another old bridge in the room? - const match = mxid.match(/[^:]*discord[^:]*_([0-9]{6,}):/) // try to match @_discord_123456, @_discordpuppet_123456, etc. - if (match) return `${attributeValue} data-user-id="${match[1]}">` - // Nope, just a real Matrix user. - return whole + if (shouldProcessTextEvent) { + if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) { + let input = event.content.formatted_body + if (event.content.msgtype === "m.emote") { + input = `* ${displayName} ${input}` } - }) - // Handling mentions of rooms and room-messages - input = await handleRoomOrMessageLinks(input, di) - - // Stripping colons after mentions - input = input.replace(/( data-user-id.*?<\/a>):?/g, "$1") - input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1") - - // Element adds a bunch of
before but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those. - input = input.replace(/(?:\n|
\s*)*<\/blockquote>/g, "") - - // The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason. - // But I should not count it if it's between block elements. - input = input.replace(/(<\/?([^ >]+)[^>]*>)?\n(<\/?([^ >]+)[^>]*>)?/g, (whole, beforeContext, beforeTag, afterContext, afterTag) => { - // console.error(beforeContext, beforeTag, afterContext, afterTag) - if (typeof beforeTag !== "string" && typeof afterTag !== "string") { - return "
" - } - beforeContext = beforeContext || "" - beforeTag = beforeTag || "" - afterContext = afterContext || "" - afterTag = afterTag || "" - if (!mxUtils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !mxUtils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) { - return beforeContext + "
" + afterContext - } else { - return whole - } - }) - - // Note: Element's renderers on Web and Android currently collapse whitespace, like the browser does. Turndown also collapses whitespace which is good for me. - // If later I'm using a client that doesn't collapse whitespace and I want turndown to follow suit, uncomment the following line of code, and it Just Works: - // input = input.replace(/ /g, " ") - // There is also a corresponding test to uncomment, named "event2message: whitespace is retained" - - // Handling written @mentions: we need to look for candidate Discord members to join to the room - // This shouldn't apply to code blocks, links, or inside attributes. So editing the HTML tree instead of regular expressions is a sensible choice here. - // We're using the domino parser because Turndown uses the same and can reuse this tree. - const doc = domino.createDocument( - // DOM parsers arrange elements in the and . Wrapping in a custom element ensures elements are reliably arranged in a single element. - '' + input + '' - ); - const root = doc.getElementById("turndown-root"); - async function forEachNode(node) { - for (; node; node = node.nextSibling) { - // Check written mentions - if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) { - const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di) - if (result) { - node.nodeValue = result.content - ensureJoined.push(...result.ensureJoined) - allowedMentionsParse.push(...result.allowedMentionsParse) - } + // Handling mentions of Discord users + input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => { + mxid = decodeURIComponent(mxid) + if (mxUtils.eventSenderIsFromDiscord(mxid)) { + // Handle mention of an OOYE sim user by their mxid + const id = select("sim", "user_id", {mxid}).pluck().get() + if (!id) return whole + return `${attributeValue} data-user-id="${id}">` + } else { + // Handle mention of a Matrix user by their mxid + // Check if this Matrix user is actually the sim user from another old bridge in the room? + const match = mxid.match(/[^:]*discord[^:]*_([0-9]{6,}):/) // try to match @_discord_123456, @_discordpuppet_123456, etc. + if (match) return `${attributeValue} data-user-id="${match[1]}">` + // Nope, just a real Matrix user. + return whole } - // Check for incompatible backticks in code blocks - let preNode - if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) { - if (preNode.firstChild?.nodeName === "CODE") { - const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt" - const filename = `inline_code.${ext}` - // Build the replacement node - const replacementCode = doc.createElement("code") - replacementCode.textContent = `[${filename}]` - // Build its containing node - const replacement = doc.createElement("span") - replacement.appendChild(doc.createTextNode(" ")) - replacement.appendChild(replacementCode) - replacement.appendChild(doc.createTextNode(" ")) - // Replace the code block with the - preNode.replaceWith(replacement) - // Upload the code as an attachment - const content = getCodeContent(preNode.firstChild) - attachments.push({id: String(attachments.length), filename}) - pendingFiles.push({name: filename, buffer: Buffer.from(content, "utf8")}) - } + }) + + // Handling mentions of rooms and room-messages + input = await handleRoomOrMessageLinks(input, di) + + // Stripping colons after mentions + input = input.replace(/( data-user-id.*?<\/a>):?/g, "$1") + input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1") + + // Element adds a bunch of
before but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those. + input = input.replace(/(?:\n|
\s*)*<\/blockquote>/g, "") + + // The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason. + // But I should not count it if it's between block elements. + input = input.replace(/(<\/?([^ >]+)[^>]*>)?\n(<\/?([^ >]+)[^>]*>)?/g, (whole, beforeContext, beforeTag, afterContext, afterTag) => { + // console.error(beforeContext, beforeTag, afterContext, afterTag) + if (typeof beforeTag !== "string" && typeof afterTag !== "string") { + return "
" + } + beforeContext = beforeContext || "" + beforeTag = beforeTag || "" + afterContext = afterContext || "" + afterTag = afterTag || "" + if (!mxUtils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !mxUtils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) { + return beforeContext + "
" + afterContext + } else { + return whole + } + }) + + // Note: Element's renderers on Web and Android currently collapse whitespace, like the browser does. Turndown also collapses whitespace which is good for me. + // If later I'm using a client that doesn't collapse whitespace and I want turndown to follow suit, uncomment the following line of code, and it Just Works: + // input = input.replace(/ /g, " ") + // There is also a corresponding test to uncomment, named "event2message: whitespace is retained" + + // Handling written @mentions: we need to look for candidate Discord members to join to the room + // This shouldn't apply to code blocks, links, or inside attributes. So editing the HTML tree instead of regular expressions is a sensible choice here. + // We're using the domino parser because Turndown uses the same and can reuse this tree. + const doc = domino.createDocument( + // DOM parsers arrange elements in the and . Wrapping in a custom element ensures elements are reliably arranged in a single element. + '' + input + '' + ); + const root = doc.getElementById("turndown-root"); + async function forEachNode(node) { + for (; node; node = node.nextSibling) { + // Check written mentions + if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) { + const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di) + if (result) { + node.nodeValue = result.content + ensureJoined.push(...result.ensureJoined) + allowedMentionsParse.push(...result.allowedMentionsParse) + } + } + // Check for incompatible backticks in code blocks + let preNode + if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) { + if (preNode.firstChild?.nodeName === "CODE") { + const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt" + const filename = `inline_code.${ext}` + // Build the replacement node + const replacementCode = doc.createElement("code") + replacementCode.textContent = `[${filename}]` + // Build its containing node + const replacement = doc.createElement("span") + replacement.appendChild(doc.createTextNode(" ")) + replacement.appendChild(replacementCode) + replacement.appendChild(doc.createTextNode(" ")) + // Replace the code block with the + preNode.replaceWith(replacement) + // Upload the code as an attachment + const content = getCodeContent(preNode.firstChild) + attachments.push({id: String(attachments.length), filename}) + pendingFiles.push({name: filename, buffer: Buffer.from(content, "utf8")}) + } + } + await forEachNode(node.firstChild) } - await forEachNode(node.firstChild) } + await forEachNode(root) + + // SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet. + // First we need to determine which emojis are at the end. + endOfMessageEmojis = [] + let match + let last = input.length + while ((match = input.slice(0, last).match(/]*>\s*$/))) { + if (!match[0].includes("data-mx-emoticon")) break + const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/) + if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1]) + assert(typeof match.index === "number", "Your JavaScript implementation does not comply with TC39: https://tc39.es/ecma262/multipage/text-processing.html#sec-regexpbuiltinexec") + last = match.index + } + + // @ts-ignore bad type from turndown + content = turndownService.turndown(root) + + // Put < > around any surviving matrix.to links to hide the URL previews + content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/g, "<$&>") + + // It's designed for commonmark, we need to replace the space-space-newline with just newline + content = content.replace(/ \n/g, "\n") + + // If there's a blockquote at the start of the message body and this message is a reply, they should be visually separated + if (replyLine && content.startsWith("> ")) content = "\n" + content + + // SPRITE SHEET EMOJIS FEATURE: + content = await uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, di?.mxcDownloader) + } else { + // Looks like we're using the plaintext body! + content = event.content.body + + if (event.content.msgtype === "m.emote") { + content = `* ${displayName} ${content}` + } + + content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible + content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/, "<$&>") // Put < > around any surviving matrix.to links to hide the URL previews + + const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di) + if (result) { + content = result.content + ensureJoined.push(...result.ensureJoined) + allowedMentionsParse.push(...result.allowedMentionsParse) + } + + // Markdown needs to be escaped, though take care not to escape the middle of links + // @ts-ignore bad type from turndown + content = turndownService.escape(content) } - await forEachNode(root) - - // SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet. - // First we need to determine which emojis are at the end. - endOfMessageEmojis = [] - let match - let last = input.length - while ((match = input.slice(0, last).match(/]*>\s*$/))) { - if (!match[0].includes("data-mx-emoticon")) break - const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/) - if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1]) - assert(typeof match.index === "number", "Your JavaScript implementation does not comply with TC39: https://tc39.es/ecma262/multipage/text-processing.html#sec-regexpbuiltinexec") - last = match.index - } - - // @ts-ignore bad type from turndown - content = turndownService.turndown(root) - - // Put < > around any surviving matrix.to links to hide the URL previews - content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/g, "<$&>") - - // It's designed for commonmark, we need to replace the space-space-newline with just newline - content = content.replace(/ \n/g, "\n") - - // If there's a blockquote at the start of the message body and this message is a reply, they should be visually separated - if (replyLine && content.startsWith("> ")) content = "\n" + content - - // SPRITE SHEET EMOJIS FEATURE: - content = await uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, di?.mxcDownloader) - } else { - // Looks like we're using the plaintext body! - content = event.content.body - - if (event.content.msgtype === "m.emote") { - content = `* ${displayName} ${content}` - } - - content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible - content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/, "<$&>") // Put < > around any surviving matrix.to links to hide the URL previews - - const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di) - if (result) { - content = result.content - ensureJoined.push(...result.ensureJoined) - allowedMentionsParse.push(...result.allowedMentionsParse) - } - - // Markdown needs to be escaped, though take care not to escape the middle of links - // @ts-ignore bad type from turndown - content = turndownService.escape(content) } } diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 73ca4e9..2e347f5 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -2671,6 +2671,99 @@ test("event2message: rich reply to a state event with no body", async t => { ) }) +test("event2message: rich reply with an image", async t => { + let called = 0 + t.deepEqual( + await eventToMessage({ + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + body: "image.png", + info: { + size: 470379, + mimetype: "image/png", + thumbnail_info: { + w: 800, + h: 450, + mimetype: "image/png", + size: 183014 + }, + w: 1920, + h: 1080, + "xyz.amorgan.blurhash": "L24_wtVt00xuxvR%NFX74Toz?waL", + thumbnail_url: "mxc://cadence.moe/lPtnjlleowWCXGOHKVDyoXGn" + }, + msgtype: "m.image", + "m.relates_to": { + "m.in_reply_to": { + event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4" + } + }, + url: "mxc://cadence.moe/yxMobQMbSqNHpajxgSHtaooG" + }, + origin_server_ts: 1764127662631, + unsigned: { + membership: "join", + age: 97, + transaction_id: "m1764127662540.2" + }, + event_id: "$QOxkw7u8vjTrrdKxEUO13JWSixV7UXAZU1freT1SkHc", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + }, data.guild.general, { + api: { + getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4") + return { + type: "m.room.message", + sender: "@cadence:cadence.moe", + content: { + msgtype: "m.text", + body: "you have to check every diff above insane on this set https://osu.ppy.sh/beatmapsets/2263303#osu/4826296" + }, + origin_server_ts: 1763639396419, + unsigned: { + membership: "join", + age: 486586696, + transaction_id: "m1763639396324.578" + }, + event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", + room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + } + } + } + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [ + { + content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/112760669178241024/1128118177155526666 **â“‚cadence [they]**: you have to check every diff above insane on this...", + allowed_mentions: { + parse: ["users", "roles"] + }, + attachments: [ + { + filename: "image.png", + id: "0", + }, + ], + avatar_url: undefined, + pendingFiles: [ + { + mxc: "mxc://cadence.moe/yxMobQMbSqNHpajxgSHtaooG", + name: "image.png", + }, + ], + username: "cadence [they]", + }, + ] + } + ) +}) + test("event2message: raw mentioning discord users in plaintext body works", async t => { t.deepEqual( await eventToMessage({ From f176b547ce2d0ec92e4fbba80fe8f2399d5d836a Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 27 Nov 2025 21:48:49 +1300 Subject: [PATCH 2/5] Maybe accept invites more reliably --- src/d2m/actions/create-room.js | 1 + src/m2d/event-dispatcher.js | 16 +++++-- src/matrix/api.js | 19 ++++++++ src/types.d.ts | 81 ++++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 61e79f3..87c3701 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -568,6 +568,7 @@ module.exports.createAllForGuild = createAllForGuild module.exports.channelToKState = channelToKState module.exports.postApplyPowerLevels = postApplyPowerLevels module.exports._convertNameAndTopic = convertNameAndTopic +module.exports._syncSpaceMember = _syncSpaceMember module.exports.unbridgeChannel = unbridgeChannel module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel module.exports.existsOrAutocreatable = existsOrAutocreatable diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index ce3638c..1f816db 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -322,14 +322,25 @@ sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member", */ async event => { if (event.state_key[0] !== "@") return + const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}` - if (event.content.membership === "invite" && event.state_key === `@${reg.sender_localpart}:${reg.ooye.server_name}`) { + if (event.content.membership === "invite" && event.state_key === bot) { // We were invited to a room. We should join, and register the invite details for future reference in web. + let attemptedApiMessage = "According to unsigned invite data." + let inviteRoomState = event.unsigned?.invite_room_state + if (!Array.isArray(inviteRoomState) || inviteRoomState.length === 0) { + try { + inviteRoomState = await api.getInviteState(event.room_id) + attemptedApiMessage = "According to SSS API." + } catch (e) { + attemptedApiMessage = "According to unsigned invite data. SSS API unavailable: " + e.toString() + } + } const name = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.name", "name") const topic = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.topic", "topic") const avatar = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.avatar", "url") const creationType = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.create", "type") - if (!name) return await api.leaveRoomWithReason(event.room_id, "Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite!") + if (!name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite! (${attemptedApiMessage})`) await api.joinRoom(event.room_id) db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, creationType, name, topic, avatar) if (avatar) utils.getPublicUrlForMxc(avatar) // make sure it's available in the media_proxy allowed URLs @@ -342,7 +353,6 @@ async event => { db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) // Unregister room's use as a direct chat if the bot itself left - const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}` if (event.state_key === bot) { db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id) } diff --git a/src/matrix/api.js b/src/matrix/api.js index e529d0f..d0892ff 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -137,6 +137,24 @@ function getStateEvent(roomID, type, key) { return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}`) } +/** + * @param {string} roomID + * @returns {Promise} + */ +async function getInviteState(roomID) { + /** @type {Ty.R.SSS} */ + const root = await mreq.mreq("POST", "/client/unstable/org.matrix.simplified_msc3575/sync", { + room_subscriptions: { + [roomID]: { + timeline_limit: 0, + required_state: [] + } + } + }) + const roomResponse = root.rooms[roomID] + return "stripped_state" in roomResponse ? roomResponse.stripped_state : roomResponse.invite_state +} + /** * "Any of the AS's users must be in the room. This API is primarily for Application Services and should be faster to respond than /members as it can be implemented more efficiently on the server." * @param {string} roomID @@ -483,6 +501,7 @@ module.exports.getEvent = getEvent module.exports.getEventForTimestamp = getEventForTimestamp module.exports.getAllState = getAllState module.exports.getStateEvent = getStateEvent +module.exports.getInviteState = getInviteState module.exports.getJoinedMembers = getJoinedMembers module.exports.getMembers = getMembers module.exports.getHierarchy = getHierarchy diff --git a/src/types.d.ts b/src/types.d.ts index cafd9be..f9488b9 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -166,6 +166,37 @@ export namespace Event { content: any } + export type InviteStrippedState = { + type: string + state_key: string + sender: string + content: Event.M_Room_Create | Event.M_Room_Name | Event.M_Room_Avatar | Event.M_Room_Topic | Event.M_Room_JoinRules | Event.M_Room_CanonicalAlias + } + + export type M_Room_Create = { + additional_creators: string[] + "m.federate"?: boolean + room_version: string + type?: string + predecessor?: { + room_id: string + event_id?: string + } + } + + export type M_Room_JoinRules = { + join_rule: "public" | "knock" | "invite" | "private" | "restricted" | "knock_restricted" + allow?: { + type: string + room_id: string + }[] + } + + export type M_Room_CanonicalAlias = { + alias?: string + alt_aliases?: string[] + } + export type M_Room_Message = { msgtype: "m.text" | "m.emote" body: string @@ -375,8 +406,58 @@ export namespace R { room_id: string servers: string[] } + + export type SSS = { + pos: string + lists: { + [list_key: string]: { + count: number + } + } + rooms: { + [room_id: string]: { + bump_stamp: number + /** Omitted if user not in room (peeking) */ + membership?: Membership + /** Names of lists that match this room */ + lists: string[] + } + // If user has been in the room - at least, that's what the spec says. Synapse returns some of these, such as `name` and `avatar`, for invites as well. Go nuts. + & { + name?: string + avatar?: string + heroes?: any[] + /** According to account data */ + is_dm?: boolean + /** If false, omitted fields are unchanged from their previous value. If true, omitted fields means the fields are not set. */ + initial?: boolean + expanded_timeline?: boolean + required_state?: Event.StateOuter[] + timeline_events?: Event.Outer[] + prev_batch?: string + limited?: boolean + num_live?: number + joined_count?: number + invited_count?: number + notification_count?: number + highlight_count?: number + } + // If user is invited or knocked + & ({ + /** @deprecated */ + invite_state: Event.InviteStrippedState[] + } | { + stripped_state: Event.InviteStrippedState[] + }) + } + extensions: { + [extension_key: string]: any + } + } } +export type Membership = "invite" | "knock" | "join" | "leave" | "ban" + export type Pagination = { chunk: T[] next_batch?: string From 493bc2560222f64d0359dc54771b5c8dafb072c7 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 28 Nov 2025 17:20:44 +1300 Subject: [PATCH 3/5] Fix unbridging procedure --- src/d2m/actions/create-room.js | 4 ++-- src/m2d/event-dispatcher.js | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 87c3701..4e59a17 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -434,7 +434,7 @@ async function unbridgeChannel(channelID) { async function unbridgeDeletedChannel(channel, guildID) { const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() assert.ok(roomID) - const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get() + const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get() assert.ok(row) let botInRoom = true @@ -458,7 +458,7 @@ async function unbridgeDeletedChannel(channel, guildID) { // delete webhook on discord const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get() if (webhook) { - await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token) + await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token).catch(() => {}) db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(channel.id) } diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 1f816db..c102d01 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -7,6 +7,7 @@ const util = require("util") const Ty = require("../types") const {discord, db, sync, as, select} = require("../passthrough") +const {tag} = require("@cloudrac3r/html-template-tag") /** @type {import("./actions/send-event")} */ const sendEvent = sync.require("./actions/send-event") @@ -121,10 +122,10 @@ async function sendError(roomID, source, type, e, payload) { // Where const stack = stringifyErrorStack(e) - builder.addLine(`Error trace:\n${stack}`, `
Error trace
${stack}
`) + builder.addLine(`Error trace:\n${stack}`, tag`
Error trace
${stack}
`) // How - builder.addLine("", `
Original payload
${util.inspect(payload, false, 4, false)}
`) + builder.addLine("", tag`
Original payload
${util.inspect(payload, false, 4, false)}
`) } // Send From c7313035a45396a9b0e9fdb1a73c9c3237714ab7 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 1 Dec 2025 16:48:11 +1300 Subject: [PATCH 4/5] Update global profiles for sims --- src/d2m/actions/register-pk-user.js | 11 +---- src/d2m/actions/register-user.js | 59 +++++++++++++++++++++--- src/d2m/actions/register-webhook-user.js | 11 +---- src/matrix/api.js | 35 +++++++++++--- 4 files changed, 85 insertions(+), 31 deletions(-) diff --git a/src/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js index e17f061..bf573e7 100644 --- a/src/d2m/actions/register-pk-user.js +++ b/src/d2m/actions/register-pk-user.js @@ -151,16 +151,9 @@ async function syncUser(messageID, author, roomID, shouldActuallySync) { const mxid = await ensureSimJoined(pkMessage, roomID) if (shouldActuallySync) { - // Build current profile data + // Build current profile data and sync if the hash has changed const content = await memberToStateContent(pkMessage, author) - const currentHash = registerUser._hashProfileContent(content, 0) - const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() - - // Only do the actual sync if the hash has changed since we last looked - if (existingHash !== currentHash) { - await api.sendState(roomID, "m.room.member", mxid, content, mxid) - db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) - } + await registerUser._sendSyncUser(roomID, mxid, content, null) } return mxid diff --git a/src/d2m/actions/register-user.js b/src/d2m/actions/register-user.js index 674853a..b58ad66 100644 --- a/src/d2m/actions/register-user.js +++ b/src/d2m/actions/register-user.js @@ -23,6 +23,8 @@ let hasher = null // @ts-ignore require("xxhash-wasm")().then(h => hasher = h) +const supportsMsc4069 = api.versions().then(v => !!v?.unstable_features?.["org.matrix.msc4069"]).catch(() => false) + /** * A sim is an account that is being simulated by the bridge to copy events from the other side. * @param {DiscordTypes.APIUser} user @@ -98,6 +100,23 @@ async function ensureSimJoined(user, roomID) { return mxid } +/** + * @param {DiscordTypes.APIUser} user + */ +async function userToGlobalProfile(user) { + const globalProfile = {} + + globalProfile.displayname = user.username + if (user.global_name) globalProfile.displayname = user.global_name + + if (user.avatar) { + const avatarPath = file.userAvatar(user) // the user avatar only + globalProfile.avatar_url = await file.uploadDiscordFileToMxc(avatarPath) + } + + return globalProfile +} + /** * @param {DiscordTypes.APIUser} user * @param {Omit | undefined} member @@ -201,21 +220,45 @@ async function syncUser(user, member, channel, guild, roomID) { const mxid = await ensureSimJoined(user, roomID) const content = await memberToStateContent(user, member, guild.id) const powerLevel = memberToPowerLevel(user, member, guild, channel) - const currentHash = _hashProfileContent(content, powerLevel) + await _sendSyncUser(roomID, mxid, content, powerLevel, { + // do not overwrite pre-existing data if we already have data and `member` is not accessible, because this would replace good data with bad data + allowOverwrite: !!member, + globalProfile: await userToGlobalProfile(user) + }) + return mxid +} + +/** + * @param {string} roomID + * @param {string} mxid + * @param {{displayname: string, avatar_url?: string}} content + * @param {number | null} powerLevel + * @param {{allowOverwrite?: boolean, globalProfile?: {displayname: string, avatar_url?: string}}} [options] + */ +async function _sendSyncUser(roomID, mxid, content, powerLevel, options) { + const currentHash = _hashProfileContent(content, powerLevel ?? 0) const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() // only do the actual sync if the hash has changed since we last looked const hashHasChanged = existingHash !== currentHash - // however, do not overwrite pre-existing data if we already have data and `member` is not accessible, because this would replace good data with bad data - const wouldOverwritePreExisting = existingHash && !member - if (hashHasChanged && !wouldOverwritePreExisting) { + // always okay to add new data. for overwriting, restrict based on options.allowOverwrite, if present + const overwriteOkay = !existingHash || (options?.allowOverwrite ?? true) + if (hashHasChanged && overwriteOkay) { + const actions = [] // Update room member state - await api.sendState(roomID, "m.room.member", mxid, content, mxid) + actions.push(api.sendState(roomID, "m.room.member", mxid, content, mxid)) // Update power levels - await api.setUserPower(roomID, mxid, powerLevel) + if (powerLevel != null) { + actions.push(api.setUserPower(roomID, mxid, powerLevel)) + } + // Update global profile (if supported by server) + if (await supportsMsc4069) { + actions.push(api.profileSetDisplayname(mxid, options?.globalProfile?.displayname || content.displayname, true)) + actions.push(api.profileSetAvatarUrl(mxid, options?.globalProfile?.avatar_url || content.avatar_url, true)) + } + await Promise.all(actions) // Update cached hash db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) } - return mxid } /** @@ -254,5 +297,7 @@ module.exports._hashProfileContent = _hashProfileContent module.exports.ensureSim = ensureSim module.exports.ensureSimJoined = ensureSimJoined module.exports.syncUser = syncUser +module.exports._sendSyncUser = _sendSyncUser module.exports.syncAllUsersInRoom = syncAllUsersInRoom module.exports._memberToPowerLevel = memberToPowerLevel +module.exports.supportsMsc4069 = supportsMsc4069 diff --git a/src/d2m/actions/register-webhook-user.js b/src/d2m/actions/register-webhook-user.js index 869d7d8..309a120 100644 --- a/src/d2m/actions/register-webhook-user.js +++ b/src/d2m/actions/register-webhook-user.js @@ -128,16 +128,9 @@ async function syncUser(author, roomID, shouldActuallySync) { const mxid = await ensureSimJoined(fakeUserID, author, roomID) if (shouldActuallySync) { - // Build current profile data + // Build current profile data and sync if the hash has changed const content = await authorToStateContent(author) - const currentHash = registerUser._hashProfileContent(content, 0) - const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() - - // Only do the actual sync if the hash has changed since we last looked - if (existingHash !== currentHash) { - await api.sendState(roomID, "m.room.member", mxid, content, mxid) - db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) - } + await registerUser._sendSyncUser(roomID, mxid, content, null) } return mxid diff --git a/src/matrix/api.js b/src/matrix/api.js index d0892ff..d746b25 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -317,16 +317,34 @@ async function sendTyping(roomID, isTyping, mxid, duration) { }) } -async function profileSetDisplayname(mxid, displayname) { - await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid), { +/** + * @param {string} mxid + * @param {string} displayname + * @param {boolean} [inhibitPropagate] + */ +async function profileSetDisplayname(mxid, displayname, inhibitPropagate) { + const params = {} + if (inhibitPropagate) params["org.matrix.msc4069.propagate"] = false + await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid, params), { displayname }) } -async function profileSetAvatarUrl(mxid, avatar_url) { - await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid), { - avatar_url - }) +/** + * @param {string} mxid + * @param {string} avatar_url + * @param {boolean} [inhibitPropagate] + */ +async function profileSetAvatarUrl(mxid, avatar_url, inhibitPropagate) { + const params = {} + if (inhibitPropagate) params["org.matrix.msc4069.propagate"] = false + if (avatar_url) { + await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid, params), { + avatar_url + }) + } else { + await mreq.mreq("DELETE", path(`/client/v3/profile/${mxid}/avatar_url`, mxid, params)) + } } /** @@ -490,6 +508,10 @@ function getProfile(mxid) { return mreq.mreq("GET", `/client/v3/profile/${mxid}`) } +function versions() { + return mreq.mreq("GET", "/client/versions") +} + module.exports.path = path module.exports.register = register module.exports.createRoom = createRoom @@ -526,3 +548,4 @@ module.exports.getAccountData = getAccountData module.exports.setAccountData = setAccountData module.exports.setPresence = setPresence module.exports.getProfile = getProfile +module.exports.versions = versions From 0bb7a27164ef6e1333465dae44a33fbea6202ed5 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Wed, 3 Dec 2025 00:55:20 +1300 Subject: [PATCH 5/5] Semaphore retries per room --- src/m2d/event-dispatcher.js | 43 +++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index c102d01..985036e 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -8,6 +8,7 @@ const util = require("util") const Ty = require("../types") const {discord, db, sync, as, select} = require("../passthrough") const {tag} = require("@cloudrac3r/html-template-tag") +const {Semaphore} = require("@chriscdn/promise-semaphore") /** @type {import("./actions/send-event")} */ const sendEvent = sync.require("./actions/send-event") @@ -153,34 +154,38 @@ function guard(type, fn) { } } +const errorRetrySema = new Semaphore() + /** * @param {Ty.Event.Outer} reactionEvent */ async function onRetryReactionAdd(reactionEvent) { const roomID = reactionEvent.room_id - const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id) + errorRetrySema.request(async () => { + const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id) - // Check that it's a real error from OOYE - const error = event.content["moe.cadence.ooye.error"] - if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return + // Check that it's a real error from OOYE + const error = event.content["moe.cadence.ooye.error"] + if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return - // To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator - if (reactionEvent.sender !== event.sender) { - // Check if it's a room moderator - const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "") - const powerLevel = powerLevelsStateContent.users?.[reactionEvent.sender] || 0 - if (powerLevel < 50) return - } + // To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator + if (reactionEvent.sender !== event.sender) { + // Check if it's a room moderator + const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "") + const powerLevel = powerLevelsStateContent.users?.[reactionEvent.sender] || 0 + if (powerLevel < 50) return + } - // Retry - if (error.source === "matrix") { - as.emit(`type:${error.payload.type}`, error.payload) - } else if (error.source === "discord") { - discord.cloud.emit("event", error.payload) - } + // Retry + if (error.source === "matrix") { + as.emit(`type:${error.payload.type}`, error.payload) + } else if (error.source === "discord") { + discord.cloud.emit("event", error.payload) + } - // Redact the error to stop people from executing multiple retries - await api.redactEvent(roomID, event.event_id) + // Redact the error to stop people from executing multiple retries + await api.redactEvent(roomID, event.event_id) + }, roomID) } sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",