Compare commits

..

2 commits

Author SHA1 Message Date
Bea
023e1fb55f
Look up thread IDs if channels cannot be found 2026-03-10 23:23:23 +00:00
Bea
16b63e2f2f
feat: make sims leave rooms when Discord user leaves guild
Listen for GUILD_MEMBER_REMOVE events to remove sim users from
bridged Matrix rooms when their corresponding Discord user leaves
(or is kicked/banned from) the guild. Also removes PluralKit proxy
sims owned by the departing user.

Includes a backfill check on GUILD_CREATE to clean up sims for
users who left while the bridge was offline.

Closes #58
2026-03-09 00:24:09 +00:00
29 changed files with 588 additions and 860 deletions

View file

@ -89,14 +89,15 @@ Whether you read those or not, I'm more than happy to help you 1-on-1 with codin
# Dependency justification # Dependency justification
Total transitive production dependencies: 144 Total transitive production dependencies: 134
### <font size="+2">🦕</font> ### <font size="+2">🦕</font>
* (35) better-sqlite3: SQLite is the best database, and this is the best library for it. * (31) better-sqlite3: SQLite is the best database, and this is the best library for it.
* (29) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. It has libvips prebuilts for each platform. * (27) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.)
* (26) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.) * (16) stream-mime-type@1: This seems like the best option. Version 1 is used because version 2 is ESM-only.
* (9) h3: Web server. OOYE needs this for the web UI, appservice listener, authmedia proxy, and more. * (9) h3: Web server. OOYE needs this for the appservice listener, authmedia proxy, self-service, and more.
* (11) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets.
### <font size="-1">🪱</font> ### <font size="-1">🪱</font>
@ -107,7 +108,6 @@ Total transitive production dependencies: 144
* (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE. * (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE.
* (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.) * (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.)
* (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs. * (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs.
* (0) @cloudrac3r/stream-type: Determine type of Matrix files that don't specify it in info. Switched from stream-mime-type to this.
* (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works. * (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works.
* (3) @stackoverflow/stacks: Stack Overflow design language and icons. * (3) @stackoverflow/stacks: Stack Overflow design language and icons.
* (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer. * (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer.
@ -115,12 +115,12 @@ Total transitive production dependencies: 144
* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust. * (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust.
* (0) discord-api-types: Bitfields needed at runtime and types needed for development. * (0) discord-api-types: Bitfields needed at runtime and types needed for development.
* (0) domino: DOM implementation that's already pulled in by turndown. * (0) domino: DOM implementation that's already pulled in by turndown.
* (2) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively. * (1) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively.
* (0) entities: Looks fine. No dependencies. * (0) entities: Looks fine. No dependencies.
* (0) get-relative-path: Looks fine. No dependencies. * (0) get-relative-path: Looks fine. No dependencies.
* (1) heatsync: Module hot-reloader that I trust. * (1) heatsync: Module hot-reloader that I trust.
* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used. * (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used.
* (1) mime-types: List of mime type mappings. Needed to serve static files. * (0) mime-type: File extension to mime type mapping that's already pulled in by stream-mime-type.
* (0) prettier-bytes: It does what I want and has no dependencies. * (0) prettier-bytes: It does what I want and has no dependencies.
* (0) snowtransfer: Discord API library with bring-your-own-caching that I trust. * (0) snowtransfer: Discord API library with bring-your-own-caching that I trust.
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well. * (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.

867
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -19,14 +19,13 @@
}, },
"dependencies": { "dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1", "@chriscdn/promise-semaphore": "^3.0.1",
"@cloudrac3r/discord-markdown": "^2.7.0", "@cloudrac3r/discord-markdown": "^2.6.10",
"@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.1.1", "@cloudrac3r/in-your-element": "^1.1.1",
"@cloudrac3r/mixin-deep": "^3.0.1", "@cloudrac3r/mixin-deep": "^3.0.1",
"@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pngjs": "^7.0.3",
"@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/pug": "^4.0.4",
"@cloudrac3r/stream-type": "^1.0.0",
"@cloudrac3r/turndown": "^7.1.4", "@cloudrac3r/turndown": "^7.1.4",
"@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks": "^2.5.4",
"@stackoverflow/stacks-icons": "^6.0.2", "@stackoverflow/stacks-icons": "^6.0.2",
@ -47,6 +46,7 @@
"prettier-bytes": "^1.0.4", "prettier-bytes": "^1.0.4",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"snowtransfer": "^0.17.5", "snowtransfer": "^0.17.5",
"stream-mime-type": "^1.0.2",
"try-to-catch": "^4.0.5", "try-to-catch": "^4.0.5",
"uqr": "^0.1.2", "uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2", "xxhash-wasm": "^1.0.2",

View file

@ -122,7 +122,7 @@ async function channelToKState(channel, guild, di) {
join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]}
} }
const everyonePermissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites) const everyonePermissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites)
const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages) const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages)
const everyoneCanMentionEveryone = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone) const everyoneCanMentionEveryone = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone)
@ -442,9 +442,8 @@ function syncRoom(channelID) {
/** /**
* @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional) * @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional)
* @param {string} guildID * @param {string} guildID
* @param {string} messageBeforeLeave
*/ */
async function unbridgeChannel(channel, guildID, messageBeforeLeave = "This room was removed from the bridge.") { async function unbridgeChannel(channel, guildID) {
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
assert.ok(roomID) assert.ok(roomID)
const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get() const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").where({guild_id: guildID}).get()
@ -494,7 +493,7 @@ async function unbridgeChannel(channel, guildID, messageBeforeLeave = "This room
// send a notification in the room // send a notification in the room
await api.sendEvent(roomID, "m.room.message", { await api.sendEvent(roomID, "m.room.message", {
msgtype: "m.notice", msgtype: "m.notice",
body: `⚠️ ${messageBeforeLeave}` body: "⚠️ This room was removed from the bridge."
}) })
// if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged // if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged

View file

@ -154,7 +154,7 @@ function memberToPowerLevel(user, member, guild, channel) {
if (!member) return 0 if (!member) return 0
const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, user.id, channel.permission_overwrites) const permissions = dUtils.getPermissions(guild.id, member.roles, guild.roles, user.id, channel.permission_overwrites)
const everyonePermissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites) const everyonePermissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites)
/* /*
* PL 100 = Administrator = People who can brick the room. RATIONALE: * PL 100 = Administrator = People who can brick the room. RATIONALE:
* - Administrator. * - Administrator.

View file

@ -151,11 +151,9 @@ async function editToChanges(message, guild, api) {
const messageReallyOld = message.timestamp && new Date(message.timestamp).getTime() < Date.now() - 2 * 60 * 1000 // older than 2 minutes ago const messageReallyOld = message.timestamp && new Date(message.timestamp).getTime() < Date.now() - 2 * 60 * 1000 // older than 2 minutes ago
// Don't post new generated embeds for messages if the setting was disabled. // Don't post new generated embeds for messages if the setting was disabled.
const embedsEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1 const embedsEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1
// Bots may rely on embeds to send new content, so the rules may be more lax for them.
const botEmbedsApproved = message.author?.bot && !originallyFromMatrix
if (messageReallyOld) { if (messageReallyOld) {
eventsToSend = [] // Only allow edits to change and delete, but not send new. eventsToSend = [] // Only allow edits to change and delete, but not send new.
} else if ((messageQuiteOld || !embedsEnabled) && !botEmbedsApproved) { } else if ((messageQuiteOld || !embedsEnabled) && !message.author?.bot) {
eventsToSend = eventsToSend.filter(e => e.msgtype !== "m.notice") // Only send events that aren't embeds. eventsToSend = eventsToSend.filter(e => e.msgtype !== "m.notice") // Only send events that aren't embeds.
} }

View file

@ -78,7 +78,7 @@ test("edit2changes: bot response", async t => {
newContent: { newContent: {
$type: "m.room.message", $type: "m.room.message",
msgtype: "m.text", msgtype: "m.text",
body: "* :ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", body: "* :ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '* <img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.', formatted_body: '* <img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
"m.mentions": { "m.mentions": {
@ -87,7 +87,7 @@ test("edit2changes: bot response", async t => {
// *** Replaced With: *** // *** Replaced With: ***
"m.new_content": { "m.new_content": {
msgtype: "m.text", msgtype: "m.text",
body: ":ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", body: ":ae_botrac4r: [@cadence](https://matrix.to/#/@cadence:cadence.moe) asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.', formatted_body: '<img data-mx-emoticon height="32" src="mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs" title=":ae_botrac4r:" alt=":ae_botrac4r:"> <a href="https://matrix.to/#/@cadence:cadence.moe">@cadence</a> asked <code>­</code>, I respond: Stop drinking paint. (No)<br><br>Hit <img data-mx-emoticon height="32" src="mxc://cadence.moe/OIpqpfxTnHKokcsYqDusxkBT" title=":bn_re:" alt=":bn_re:"> to reroll.',
"m.mentions": { "m.mentions": {

View file

@ -146,18 +146,10 @@ function findMention(pjr, maximumWrittenSection, baseOffset, prefix, content) {
// Highlight the relevant part of the message // Highlight the relevant part of the message
const start = baseOffset + best.scored.matchedInputTokens[0].index const start = baseOffset + best.scored.matchedInputTokens[0].index
const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end const end = baseOffset + prefix.length + best.scored.matchedInputTokens.slice(-1)[0].end
const newNodes = [{ const newContent = content.slice(0, start) + "[" + content.slice(start, end) + "](https://matrix.to/#/" + best.mxid + ")" + content.slice(end)
type: "text", content: content.slice(0, start)
}, {
type: "link", target: `https://matrix.to/#/${best.mxid}`, content: [
{type: "text", content: content.slice(start, end)}
]
}, {
type: "text", content: content.slice(end)
}]
return { return {
mxid: best.mxid, mxid: best.mxid,
newNodes newContent
} }
} }
} }

View file

@ -261,29 +261,6 @@ function getFormattedInteraction(interaction, isThinkingInteraction) {
} }
} }
/**
* @param {any} newEvents merge into events
* @param {any} events will be modified
* @param {boolean} forceSameMsgtype whether m.text may only be combined with m.text, etc
*/
function mergeTextEvents(newEvents, events, forceSameMsgtype) {
let prev = events.at(-1)
for (const ne of newEvents) {
const isAllText = prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(ne.msgtype) && ["m.text", "m.notice"].includes(prev?.msgtype)
const typesPermitted = !forceSameMsgtype || ne?.msgtype === prev?.msgtype
if (isAllText && typesPermitted) {
const rep = new mxUtils.MatrixStringBuilder()
rep.body = prev.body
rep.formattedBody = prev.formatted_body
rep.addLine(ne.body, ne.formatted_body)
prev.body = rep.body
prev.formatted_body = rep.formattedBody
} else {
events.push(ne)
}
}
}
/** /**
* @param {DiscordTypes.APIMessage} message * @param {DiscordTypes.APIMessage} message
* @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuild} guild
@ -542,60 +519,29 @@ async function messageToEvent(message, guild, options = {}, di) {
return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed
})) }))
async function transformParsedVia(parsed, scanTextForMentions) { async function transformParsedVia(parsed) {
for (let n = 0; n < parsed.length; n++) { for (const node of parsed) {
const node = parsed[n]
if (node.type === "discordChannel" || node.type === "discordChannelLink") { if (node.type === "discordChannel" || node.type === "discordChannelLink") {
node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get() node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
if (node.row?.room_id) { if (node.row?.room_id) {
node.via = await getViaServersMemo(node.row.room_id) node.via = await getViaServersMemo(node.row.room_id)
} }
} }
else if (node.type === "text" && typeof node.content === "string") {
// Merge adjacent text nodes into this one
while (parsed[n+1]?.type === "text" && typeof parsed[n+1].content === "string") {
node.content += parsed[n+1].content
parsed.splice(n+1, 1)
}
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
if (scanTextForMentions) {
let content = node.content
const matches = [...content.matchAll(/(@ ?)([a-z0-9_.#$][^@\n]+)/gi)]
for (let i = matches.length; i--;) {
const m = matches[i]
const prefix = m[1]
const maximumWrittenSection = m[2].toLowerCase()
if (m.index > 0 && !content[m.index-1].match(/ |\(|\n/)) continue // must have space before it
if (maximumWrittenSection.match(/^everyone\b/) || maximumWrittenSection.match(/^here\b/)) continue // ignore @everyone/@here
var roomID = roomID ?? select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID)
var pjr = pjr ?? findMentions.processJoined(Object.entries((await di.api.getJoinedMembers(roomID)).joined).map(([mxid, ev]) => ({mxid, displayname: ev.display_name})))
const found = findMentions.findMention(pjr, maximumWrittenSection, m.index, prefix, content)
if (found) {
addMention(found.mxid)
parsed.splice(n, 1, ...found.newNodes)
content = found.newNodes[0].content
}
}
}
}
for (const maybeChildNodesArray of [node, node.content, node.items]) { for (const maybeChildNodesArray of [node, node.content, node.items]) {
if (Array.isArray(maybeChildNodesArray)) { if (Array.isArray(maybeChildNodesArray)) {
await transformParsedVia(maybeChildNodesArray, scanTextForMentions && ["blockQuote", "list", "paragraph", "em", "strong", "u", "del", "text"].includes(node.type)) await transformParsedVia(maybeChildNodesArray)
} }
} }
} }
return parsed return parsed
} }
let html = await markdown.toHtmlWithPostParser(content, parsed => transformParsedVia(parsed, customOptions.isTheMessageContent && options.scanTextForMentions !== false), { let html = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
discordCallback: getDiscordParseCallbacks(message, guild, true, spoilers), discordCallback: getDiscordParseCallbacks(message, guild, true, spoilers),
...customOptions ...customOptions
}, customParser, customHtmlOutput) }, customParser, customHtmlOutput)
let body = await markdown.toHtmlWithPostParser(content, parsed => transformParsedVia(parsed, false), { // not scanning plaintext body for mentions as we don't parse whether they're in code let body = await markdown.toHtmlWithPostParser(content, transformParsedVia, {
discordCallback: getDiscordParseCallbacks(message, guild, false), discordCallback: getDiscordParseCallbacks(message, guild, false),
discordOnly: true, discordOnly: true,
escapeHTML: false, escapeHTML: false,
@ -789,12 +735,35 @@ async function messageToEvent(message, guild, options = {}, di) {
// Then text content // Then text content
if (message.content && !isOnlyKlipyGIF && !isThinkingInteraction) { if (message.content && !isOnlyKlipyGIF && !isThinkingInteraction) {
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
let content = message.content
if (options.scanTextForMentions !== false) {
const matches = [...content.matchAll(/(@ ?)([a-z0-9_.#$][^@\n]+)/gi)]
for (let i = matches.length; i--;) {
const m = matches[i]
const prefix = m[1]
const maximumWrittenSection = m[2].toLowerCase()
if (m.index > 0 && !content[m.index-1].match(/ |\(|\n/)) continue // must have space before it
if (maximumWrittenSection.match(/^everyone\b/) || maximumWrittenSection.match(/^here\b/)) continue // ignore @everyone/@here
var roomID = roomID ?? select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID)
var pjr = pjr ?? findMentions.processJoined(Object.entries((await di.api.getJoinedMembers(roomID)).joined).map(([mxid, ev]) => ({mxid, displayname: ev.display_name})))
const found = findMentions.findMention(pjr, maximumWrittenSection, m.index, prefix, content)
if (found) {
addMention(found.mxid)
content = found.newContent
}
}
}
// Scan the content for emojihax and replace them with real emojis // Scan the content for emojihax and replace them with real emojis
let content = message.content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => { content = content.replaceAll(/\[([a-zA-Z0-9_-]{2,32})(?:~[0-9]+)?\]\(https:\/\/cdn\.discordapp\.com\/emojis\/([0-9]+)\.[^ \n)`]+\)/g, (_, name, id) => {
return `<:${name}:${id}>` return `<:${name}:${id}>`
}) })
const {body, html} = await transformContent(content, {isTheMessageContent: true}) const {body, html} = await transformContent(content)
await addTextEvent(body, html, msgtype) await addTextEvent(body, html, msgtype)
} }
@ -858,7 +827,15 @@ async function messageToEvent(message, guild, options = {}, di) {
// Try to merge attachment events with the previous event // Try to merge attachment events with the previous event
// This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together. // This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together.
mergeTextEvents(attachmentEvents, events, false) let prev = events.at(-1)
for (const atch of attachmentEvents) {
if (atch.msgtype === "m.text" && prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(prev?.msgtype)) {
prev.body = prev.body + "\n" + atch.body
prev.formatted_body = prev.formatted_body + "<br>" + atch.formatted_body
} else {
events.push(atch)
}
}
} }
// Then components // Then components
@ -996,7 +973,6 @@ async function messageToEvent(message, guild, options = {}, di) {
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once // Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
const rep = new mxUtils.MatrixStringBuilder() const rep = new mxUtils.MatrixStringBuilder()
let isAdditionalImage = false
if (isKlipyGIF) { if (isKlipyGIF) {
assert(embed.video?.url) assert(embed.video?.url)
@ -1063,11 +1039,7 @@ async function messageToEvent(message, guild, options = {}, di) {
let chosenImage = embed.image?.url let chosenImage = embed.image?.url
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord // the thumbnail seems to be used for "article" type but displayed big at the bottom by discord
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
if (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
if (chosenImage) {
isAdditionalImage = !rep.body && !!events.length
rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
}
if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`) if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`)
@ -1076,11 +1048,6 @@ async function messageToEvent(message, guild, options = {}, di) {
body = body.split("\n").map(l => "| " + l).join("\n") body = body.split("\n").map(l => "| " + l).join("\n")
html = `<blockquote>${html}</blockquote>` html = `<blockquote>${html}</blockquote>`
if (isAdditionalImage) {
mergeTextEvents([{...rep.get(), body, html, msgtype: "m.notice"}], events, true)
continue
}
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person // Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
await addTextEvent(body, html, "m.notice") await addTextEvent(body, html, "m.notice")
} }

View file

@ -204,44 +204,6 @@ test("message2event embeds: author url without name", async t => {
}]) }])
}) })
test("message2event embeds: 4 images", async t => {
const events = await messageToEvent(data.message_with_embeds.four_images, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "[🔀 Forwarded message]\n» https://fixupx.com/i/status/2032003668787020046",
format: "org.matrix.custom.html",
formatted_body: "🔀 <em>Forwarded message</em><br><blockquote><a href=\"https://fixupx.com/i/status/2032003668787020046\">https://fixupx.com/i/status/2032003668787020046</a></blockquote>",
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "» | ## ⏺️ AUTOMATON WEST (@AUTOMATON_ENG) https://x.com/AUTOMATON_ENG/status/2032003668787020046"
+ "\n» | "
+ "\n» | 4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanitys last non\\-AI made social network”"
+ "\n» | "
+ "\n» | [automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/](https://automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/)"
+ "\n» | "
+ "\n» | **[💬](https://x.com/intent/tweet?in_reply_to=2032003668787020046) 36[🔁](https://x.com/intent/retweet?tweet_id=2032003668787020046) 212[❤](https://x.com/intent/like?tweet_id=2032003668787020046) 3\\.0K 👁 131\\.7K**"
+ "\n» | "
+ "\n» | 📸 https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig"
+ "\n» | — FixupX"
+ "\n» | 📸 https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig"
+ "\n» | 📸 https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig"
+ "\n» | 📸 https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><blockquote><p><strong><a href=\"https://x.com/AUTOMATON_ENG/status/2032003668787020046\">⏺️ AUTOMATON WEST (@AUTOMATON_ENG)</a></strong></p>"
+ "<p>4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanitys last non-AI made social network”"
+ "<br><br><a href=\"https://automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/\">automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/</a>"
+ "<br><br><strong><a href=\"https://x.com/intent/tweet?in_reply_to=2032003668787020046\">💬</a> 36<a href=\"https://x.com/intent/retweet?tweet_id=2032003668787020046\">🔁</a> 212<a href=\"https://x.com/intent/like?tweet_id=2032003668787020046\">❤</a> 3.0K 👁 131.7K</strong></p>"
+ "<p>📸 https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig</p>— FixupX</blockquote>"
+ "<p>📸 https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig</p>"
+ "<p>📸 https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig</p>"
+ "<p>📸 https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig</p></blockquote>",
"m.mentions": {}
}])
})
test("message2event embeds: vx image", async t => { test("message2event embeds: vx image", async t => {
const events = await messageToEvent(data.message_with_embeds.vx_image, data.guild.general) const events = await messageToEvent(data.message_with_embeds.vx_image, data.guild.general)
t.deepEqual(events, [{ t.deepEqual(events, [{

View file

@ -789,7 +789,7 @@ test("message2event: simple written @mention for matrix user", async t => {
] ]
}, },
msgtype: "m.text", msgtype: "m.text",
body: "@ash do you need anything from the store btw as I'm heading there after gym", body: "[@ash](https://matrix.to/#/@she_who_brings_destruction:cadence.moe) do you need anything from the store btw as I'm heading there after gym",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<a href="https://matrix.to/#/@she_who_brings_destruction:cadence.moe">@ash</a> do you need anything from the store btw as I'm heading there after gym` formatted_body: `<a href="https://matrix.to/#/@she_who_brings_destruction:cadence.moe">@ash</a> do you need anything from the store btw as I'm heading there after gym`
}]) }])
@ -838,7 +838,7 @@ test("message2event: many written @mentions for matrix users", async t => {
] ]
}, },
msgtype: "m.text", msgtype: "m.text",
body: "@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck", body: "[@Cadence](https://matrix.to/#/@cadence:cadence.moe), tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and [@huck](https://matrix.to/#/@huckleton:cadence.moe)",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<a href="https://matrix.to/#/@cadence:cadence.moe">@Cadence</a>, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a>` formatted_body: `<a href="https://matrix.to/#/@cadence:cadence.moe">@Cadence</a>, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a>`
}]) }])
@ -890,7 +890,7 @@ test("message2event: written @mentions may match part of the name", async t => {
] ]
}, },
msgtype: "m.text", msgtype: "m.text",
body: "I wonder if @cadence saw this?", body: "I wonder if [@cadence](https://matrix.to/#/@secret:cadence.moe) saw this?",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `I wonder if <a href="https://matrix.to/#/@secret:cadence.moe">@cadence</a> saw this?` formatted_body: `I wonder if <a href="https://matrix.to/#/@secret:cadence.moe">@cadence</a> saw this?`
}]) }])
@ -941,7 +941,7 @@ test("message2event: written @mentions may match part of the mxid", async t => {
] ]
}, },
msgtype: "m.text", msgtype: "m.text",
body: "I wonder if @huck saw this?", body: "I wonder if [@huck](https://matrix.to/#/@huckleton:cadence.moe) saw this?",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `I wonder if <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a> saw this?` formatted_body: `I wonder if <a href="https://matrix.to/#/@huckleton:cadence.moe">@huck</a> saw this?`
}]) }])
@ -962,36 +962,6 @@ test("message2event: written @mentions do not match in URLs", async t => {
}]) }])
}) })
test("message2event: written @mentions do not match in inline code", async t => {
const events = await messageToEvent({
...data.message.advanced_written_at_mention_for_matrix,
content: "`public @Nullable EntityType<?>`"
}, data.guild.general, {}, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "`public @Nullable EntityType<?>`",
format: "org.matrix.custom.html",
formatted_body: `<code>public @Nullable EntityType&lt;?&gt;</code>`
}])
})
test("message2event: written @mentions do not match in code block", async t => {
const events = await messageToEvent({
...data.message.advanced_written_at_mention_for_matrix,
content: "```java\npublic @Nullable EntityType<?>\n```"
}, data.guild.general, {}, {})
t.deepEqual(events, [{
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.text",
body: "```java\npublic @Nullable EntityType<?>\n```",
format: "org.matrix.custom.html",
formatted_body: `<pre><code class="language-java">public @Nullable EntityType&lt;?&gt;</code></pre>`
}])
})
test("message2event: entire message may match elaborate display name", async t => { test("message2event: entire message may match elaborate display name", async t => {
let called = 0 let called = 0
const events = await messageToEvent({ const events = await messageToEvent({
@ -1037,7 +1007,7 @@ test("message2event: entire message may match elaborate display name", async t =
] ]
}, },
msgtype: "m.text", msgtype: "m.text",
body: "@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆", body: "[@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆](https://matrix.to/#/@wa:cadence.moe)",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: `<a href="https://matrix.to/#/@wa:cadence.moe">@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆</a>` formatted_body: `<a href="https://matrix.to/#/@wa:cadence.moe">@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆</a>`
}]) }])
@ -1114,7 +1084,7 @@ test("message2event: multiple attachments are combined into the same event where
formatted_body: "hey" formatted_body: "hey"
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)` + `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
+ `<br><blockquote>📸 Uploaded SPOILER file: <a href="https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg">https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg</a> (38 KB)</blockquote>` + `<br><blockquote>📸 Uploaded SPOILER file: <a href="https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg">https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg</a> (38 KB)</blockquote>`
+ `📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)` + `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
}, { }, {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": {}, "m.mentions": {},

View file

@ -52,11 +52,7 @@ class DiscordClient {
/** @type {Map<string, Array<string>>} */ /** @type {Map<string, Array<string>>} */
this.guildChannelMap = new Map() this.guildChannelMap = new Map()
if (listen !== "no") { if (listen !== "no") {
this.cloud.on("event", message => { this.cloud.on("event", message => discordPackets.onPacket(this, message, listen))
process.nextTick(() => {
discordPackets.onPacket(this, message, listen)
})
})
} }
const addEventLogger = (eventName, logName) => { const addEventLogger = (eventName, logName) => {

View file

@ -26,7 +26,6 @@ const utils = {
client.user = message.d.user client.user = message.d.user
client.application = message.d.application client.application = message.d.application
console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`) console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`)
interactions.registerInteractions()
} else if (message.t === "GUILD_CREATE") { } else if (message.t === "GUILD_CREATE") {
message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web
@ -48,6 +47,7 @@ const utils = {
if (listen === "full") { if (listen === "full") {
try { try {
interactions.registerInteractions()
await eventDispatcher.checkMissedExpressions(message.d) await eventDispatcher.checkMissedExpressions(message.d)
await eventDispatcher.checkMissedPins(client, message.d) await eventDispatcher.checkMissedPins(client, message.d)
await eventDispatcher.checkMissedMessages(client, message.d) await eventDispatcher.checkMissedMessages(client, message.d)

View file

@ -5,7 +5,7 @@ const assert = require("assert").strict
const {reg} = require("../matrix/read-registration") const {reg} = require("../matrix/read-registration")
const {db, select} = require("../passthrough") const {db} = require("../passthrough")
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
let hasher = null let hasher = null
@ -58,15 +58,6 @@ function getPermissions(guildID, userRoles, guildRoles, userID, channelOverwrite
return allowed return allowed
} }
/**
* @param {{id: string, roles: DiscordTypes.APIGuild["roles"]}} guild
* @param {DiscordTypes.APIGuildChannel["permission_overwrites"]} [channel]
*/
function getDefaultPermissions(guild, channel) {
const defaultRoles = select("role_default", "role_id", {guild_id: guild.id}).pluck().all()
return getPermissions(guild.id, defaultRoles, guild.roles, undefined, channel)
}
/** /**
* Note: You can only provide one permission bit to permissionToCheckFor. To check multiple permissions, call `hasAllPermissions` or `hasSomePermissions`. * Note: You can only provide one permission bit to permissionToCheckFor. To check multiple permissions, call `hasAllPermissions` or `hasSomePermissions`.
* It is designed like this to avoid developer error with bit manipulations. * It is designed like this to avoid developer error with bit manipulations.
@ -183,7 +174,6 @@ function filterTo(xs, fn) {
} }
module.exports.getPermissions = getPermissions module.exports.getPermissions = getPermissions
module.exports.getDefaultPermissions = getDefaultPermissions
module.exports.hasPermission = hasPermission module.exports.hasPermission = hasPermission
module.exports.hasSomePermissions = hasSomePermissions module.exports.hasSomePermissions = hasSomePermissions
module.exports.hasAllPermissions = hasAllPermissions module.exports.hasAllPermissions = hasAllPermissions

View file

@ -9,7 +9,7 @@ const sharp = require("sharp")
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/mreq")} */ /** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq") const mreq = sync.require("../../matrix/mreq")
const {streamType} = require("@cloudrac3r/stream-type") const streamMimeType = require("stream-mime-type")
const WIDTH = 160 const WIDTH = 160
const HEIGHT = 160 const HEIGHT = 160
@ -26,13 +26,13 @@ async function getAndResizeSticker(mxc) {
} }
const streamIn = Readable.fromWeb(res.body) const streamIn = Readable.fromWeb(res.body)
const {streamThrough, type} = await streamType(streamIn) const { stream, mime } = await streamMimeType.getMimeType(streamIn)
const animated = ["image/gif", "image/webp"].includes(type) const animated = ["image/gif", "image/webp"].includes(mime)
const transformer = sharp({animated: animated}) const transformer = sharp({animated: animated})
.resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}}) .resize(WIDTH, HEIGHT, {fit: "inside", background: {r: 0, g: 0, b: 0, alpha: 0}})
.webp() .webp()
streamThrough.pipe(transformer) stream.pipe(transformer)
return Readable.toWeb(transformer) return Readable.toWeb(transformer)
} }

View file

@ -6,7 +6,7 @@ const {pipeline} = require("stream").promises
const sharp = require("sharp") const sharp = require("sharp")
const {GIFrame} = require("@cloudrac3r/giframe") const {GIFrame} = require("@cloudrac3r/giframe")
const {PNG} = require("@cloudrac3r/pngjs") const {PNG} = require("@cloudrac3r/pngjs")
const {streamType} = require("@cloudrac3r/stream-type") const streamMimeType = require("stream-mime-type")
const SIZE = 48 const SIZE = 48
const RESULT_WIDTH = 400 const RESULT_WIDTH = 400
@ -54,11 +54,11 @@ async function compositeMatrixEmojis(mxcs, mxcDownloader) {
* @returns {Promise<Buffer | undefined>} Uncompressed PNG image * @returns {Promise<Buffer | undefined>} Uncompressed PNG image
*/ */
async function convertImageStream(streamIn, stopStream) { async function convertImageStream(streamIn, stopStream) {
const {streamThrough, type} = await streamType(streamIn) const {stream, mime} = await streamMimeType.getMimeType(streamIn)
assert(["image/png", "image/jpeg", "image/webp", "image/gif", "image/apng"].includes(type), `Mime type ${type} is impossible for emojis`) assert(["image/png", "image/jpeg", "image/webp", "image/gif", "image/apng"].includes(mime), `Mime type ${mime} is impossible for emojis`)
try { try {
if (type === "image/png" || type === "image/jpeg" || type === "image/webp") { if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp") {
/** @type {{info: sharp.OutputInfo, buffer: Buffer}} */ /** @type {{info: sharp.OutputInfo, buffer: Buffer}} */
const result = await new Promise((resolve, reject) => { const result = await new Promise((resolve, reject) => {
const transformer = sharp() const transformer = sharp()
@ -70,15 +70,15 @@ async function convertImageStream(streamIn, stopStream) {
resolve({info, buffer}) resolve({info, buffer})
}) })
pipeline( pipeline(
streamThrough, stream,
transformer transformer
) )
}) })
return result.buffer return result.buffer
} else if (type === "image/gif") { } else if (mime === "image/gif") {
const giframe = new GIFrame(0) const giframe = new GIFrame(0)
streamThrough.on("data", chunk => { stream.on("data", chunk => {
giframe.feed(chunk) giframe.feed(chunk)
}) })
const frame = await giframe.getFrame() const frame = await giframe.getFrame()
@ -91,10 +91,10 @@ async function convertImageStream(streamIn, stopStream) {
.toBuffer({resolveWithObject: true}) .toBuffer({resolveWithObject: true})
return buffer.data return buffer.data
} else if (type === "image/apng") { } else if (mime === "image/apng") {
const png = new PNG({maxFrames: 1}) const png = new PNG({maxFrames: 1})
// @ts-ignore // @ts-ignore
streamThrough.pipe(png) stream.pipe(png)
/** @type {Buffer} */ // @ts-ignore /** @type {Buffer} */ // @ts-ignore
const frame = await new Promise(resolve => png.on("parsed", resolve)) const frame = await new Promise(resolve => png.on("parsed", resolve))
stopStream() stopStream()

View file

@ -898,7 +898,7 @@ async function eventToMessage(event, guild, channel, di) {
let shouldSuppress = inBody !== -1 && event.content.body[inBody-1] === "<" let shouldSuppress = inBody !== -1 && event.content.body[inBody-1] === "<"
if (!shouldSuppress && guild?.roles) { if (!shouldSuppress && guild?.roles) {
// Suppress if regular users don't have permission // Suppress if regular users don't have permission
const permissions = dUtils.getDefaultPermissions(guild, channel?.permission_overwrites) const permissions = dUtils.getPermissions(guild.id, [], guild.roles)
const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks) const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks)
shouldSuppress = !canEmbedLinks shouldSuppress = !canEmbedLinks
} }
@ -961,7 +961,7 @@ async function eventToMessage(event, guild, channel, di) {
// Suppress if regular users don't have permission // Suppress if regular users don't have permission
if (!shouldSuppress && guild?.roles) { if (!shouldSuppress && guild?.roles) {
const permissions = dUtils.getDefaultPermissions(guild, channel.permission_overwrites) const permissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites)
const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks) const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks)
shouldSuppress = !canEmbedLinks shouldSuppress = !canEmbedLinks
} }

View file

@ -413,7 +413,6 @@ async event => {
console.error(e) console.error(e)
return await api.leaveRoomWithReason(event.room_id, `I wasn't able to find out what this room is. Please report this as a bug. Check console for more details. (${e.toString()})`) return await api.leaveRoomWithReason(event.room_id, `I wasn't able to find out what this room is. Please report this as a bug. Check console for more details. (${e.toString()})`)
} }
if (inviteRoomState?.encryption) return await api.leaveRoomWithReason(event.room_id, "Encrypted rooms are not supported for bridging. Please use an unencrypted room.")
if (!inviteRoomState?.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 (!inviteRoomState?.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.`)
await api.joinRoom(event.room_id) await api.joinRoom(event.room_id)
db.prepare("REPLACE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar) db.prepare("REPLACE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar)
@ -484,20 +483,6 @@ async event => {
await roomUpgrade.onTombstone(event, api) await roomUpgrade.onTombstone(event, api)
})) }))
sync.addTemporaryListener(as, "type:m.room.encryption", guard("m.room.encryption",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Encryption>} event
*/
async event => {
// Dramatically unbridge rooms if they become encrypted
if (event.state_key !== "") return
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
if (!channelID) return
const channel = discord.channels.get(channelID)
if (!channel) return
await createRoom.unbridgeChannel(channel, channel["guild_id"], "Encrypted rooms are not supported. This room was removed from the bridge.")
}))
module.exports.stringifyErrorStack = stringifyErrorStack module.exports.stringifyErrorStack = stringifyErrorStack
module.exports.sendError = sendError module.exports.sendError = sendError
module.exports.printError = printError module.exports.printError = printError

View file

@ -172,7 +172,7 @@ function getStateEventOuter(roomID, type, key) {
/** /**
* @param {string} roomID * @param {string} roomID
* @param {{unsigned?: {invite_room_state?: Ty.Event.InviteStrippedState[]}}} [event] * @param {{unsigned?: {invite_room_state?: Ty.Event.InviteStrippedState[]}}} [event]
* @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?, encryption: string?}>} * @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?}>}
*/ */
async function getInviteState(roomID, event) { async function getInviteState(roomID, event) {
function getFromInviteRoomState(strippedState, nskey, key) { function getFromInviteRoomState(strippedState, nskey, key) {
@ -191,8 +191,7 @@ async function getInviteState(roomID, event) {
name: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.name", "name"), name: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.name", "name"),
topic: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.topic", "topic"), topic: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.topic", "topic"),
avatar: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.avatar", "url"), avatar: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.avatar", "url"),
type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type"), type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type")
encryption: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.encryption", "algorithm")
} }
} }
@ -228,8 +227,7 @@ async function getInviteState(roomID, event) {
name: getFromInviteRoomState(strippedState, "m.room.name", "name"), name: getFromInviteRoomState(strippedState, "m.room.name", "name"),
topic: getFromInviteRoomState(strippedState, "m.room.topic", "topic"), topic: getFromInviteRoomState(strippedState, "m.room.topic", "topic"),
avatar: getFromInviteRoomState(strippedState, "m.room.avatar", "url"), avatar: getFromInviteRoomState(strippedState, "m.room.avatar", "url"),
type: getFromInviteRoomState(strippedState, "m.room.create", "type"), type: getFromInviteRoomState(strippedState, "m.room.create", "type")
encryption: getFromInviteRoomState(strippedState, "m.room.encryption", "algorithm")
} }
} }
} catch (e) {} } catch (e) {}
@ -242,8 +240,7 @@ async function getInviteState(roomID, event) {
name: room.name ?? null, name: room.name ?? null,
topic: room.topic ?? null, topic: room.topic ?? null,
avatar: room.avatar_url ?? null, avatar: room.avatar_url ?? null,
type: room.room_type ?? null, type: room.room_type ?? null
encryption: (room.encryption || room["im.nheko.summary.encryption"]) ?? null
} }
} }

View file

@ -85,7 +85,6 @@ async function _actuallyUploadDiscordFileToMxc(url) {
writeRegistration(reg) writeRegistration(reg)
return root return root
} }
e.uploadURL = url
throw e throw e
} }
} }

View file

@ -105,8 +105,7 @@ const commands = [{
// Guard // Guard
/** @type {string} */ // @ts-ignore /** @type {string} */ // @ts-ignore
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
const channel = discord.channels.get(channelID) const guildID = discord.channels.get(channelID)?.["guild_id"]
const guildID = channel?.["guild_id"]
let matrixOnlyReason = null let matrixOnlyReason = null
const matrixOnlyConclusion = "So the emoji will be uploaded on Matrix-side only. It will still be usable over the bridge, but may have degraded functionality." const matrixOnlyConclusion = "So the emoji will be uploaded on Matrix-side only. It will still be usable over the bridge, but may have degraded functionality."
// Check if we can/should upload to Discord, for various causes // Check if we can/should upload to Discord, for various causes
@ -116,7 +115,7 @@ const commands = [{
const guild = discord.guilds.get(guildID) const guild = discord.guilds.get(guildID)
assert(guild) assert(guild)
const slots = getSlotCount(guild.premium_tier) const slots = getSlotCount(guild.premium_tier)
const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"]) const permissions = dUtils.getPermissions(guild.id, [], guild.roles)
if (guild.emojis.length >= slots) { if (guild.emojis.length >= slots) {
matrixOnlyReason = "CAPACITY" matrixOnlyReason = "CAPACITY"
} else if (!(permissions & 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...) } else if (!(permissions & 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...)
@ -241,8 +240,7 @@ const commands = [{
// Guard // Guard
/** @type {string} */ // @ts-ignore /** @type {string} */ // @ts-ignore
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
const channel = discord.channels.get(channelID) const guildID = discord.channels.get(channelID)?.["guild_id"]
const guildID = channel?.["guild_id"]
if (!guildID) { if (!guildID) {
return api.sendEvent(event.room_id, "m.room.message", { return api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,
@ -253,7 +251,7 @@ const commands = [{
const guild = discord.guilds.get(guildID) const guild = discord.guilds.get(guildID)
assert(guild) assert(guild)
const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"]) const permissions = dUtils.getPermissions(guild.id, [], guild.roles)
if (!(permissions & 0x800000000n)) { // CREATE_PUBLIC_THREADS if (!(permissions & 0x800000000n)) { // CREATE_PUBLIC_THREADS
return api.sendEvent(event.room_id, "m.room.message", { return api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,
@ -272,8 +270,7 @@ const commands = [{
// Guard // Guard
/** @type {string} */ // @ts-ignore /** @type {string} */ // @ts-ignore
const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get()
const channel = discord.channels.get(channelID) const guildID = discord.channels.get(channelID)?.["guild_id"]
const guildID = channel?.["guild_id"]
if (!guildID) { if (!guildID) {
return api.sendEvent(event.room_id, "m.room.message", { return api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,
@ -284,7 +281,7 @@ const commands = [{
const guild = discord.guilds.get(guildID) const guild = discord.guilds.get(guildID)
assert(guild) assert(guild)
const permissions = dUtils.getDefaultPermissions(guild, channel["permission_overwrites"]) const permissions = dUtils.getPermissions(guild.id, [], guild.roles)
if (!dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.CreateInstantInvite)) { if (!dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.CreateInstantInvite)) {
return api.sendEvent(event.room_id, "m.room.message", { return api.sendEvent(event.room_id, "m.room.message", {
...ctx, ...ctx,
@ -293,19 +290,7 @@ const commands = [{
}) })
} }
try { const invite = await discord.snow.channel.createChannelInvite(channelID)
var invite = await discord.snow.channel.createChannelInvite(channelID)
} catch (e) {
if (e.message === `{"message": "Missing Permissions", "code": 50013}`) {
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
body: "I don't have permission to create invites to the Discord channel/server."
})
} else {
throw e
}
}
const validHours = Math.ceil(invite.max_age / (60 * 60)) const validHours = Math.ceil(invite.max_age / (60 * 60))
const validUses = const validUses =
( invite.max_uses === 0 ? "unlimited uses" ( invite.max_uses === 0 ? "unlimited uses"

View file

@ -54,17 +54,17 @@ async function onBotMembership(event, api, createRoom) {
assert.equal(event.type, "m.room.member") assert.equal(event.type, "m.room.member")
assert.equal(event.state_key, utils.bot) assert.equal(event.state_key, utils.bot)
// Check if an upgrade is pending for this room
const newRoomID = event.room_id
const oldRoomID = select("room_upgrade_pending", "old_room_id", {new_room_id: newRoomID}).pluck().get()
if (!oldRoomID) return false
const channelRow = from("channel_room").join("guild_space", "guild_id").where({room_id: oldRoomID}).select("space_id", "guild_id", "channel_id").get()
assert(channelRow) // this could only fail if the channel was unbridged or something between upgrade and joining
// Check if is join/invite
if (event.content.membership !== "invite" && event.content.membership !== "join") return false
return await roomUpgradeSema.request(async () => { return await roomUpgradeSema.request(async () => {
// Check if an upgrade is pending for this room
const newRoomID = event.room_id
const oldRoomID = select("room_upgrade_pending", "old_room_id", {new_room_id: newRoomID}).pluck().get()
if (!oldRoomID) return false
const channelRow = from("channel_room").join("guild_space", "guild_id").where({room_id: oldRoomID}).select("space_id", "guild_id", "channel_id").get()
assert(channelRow) // this could only fail if the channel was unbridged or something between upgrade and joining
// Check if is join/invite
if (event.content.membership !== "invite" && event.content.membership !== "join") return false
// If invited, join // If invited, join
if (event.content.membership === "invite") { if (event.content.membership === "invite") {
await api.joinRoom(newRoomID) await api.joinRoom(newRoomID)

9
src/types.d.ts vendored
View file

@ -157,7 +157,7 @@ export namespace Event {
type: string type: string
state_key: string state_key: string
sender: 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 | Event.M_Room_Encryption 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 = { export type M_Room_Create = {
@ -390,12 +390,6 @@ export namespace Event {
body: string body: string
replacement_room: string replacement_room: string
} }
export type M_Room_Encryption = {
algorithm: string
rotation_period_ms?: number
rotation_period_msgs?: number
}
} }
export namespace R { export namespace R {
@ -443,7 +437,6 @@ export namespace R {
num_joined_members: number num_joined_members: number
room_id: string room_id: string
room_type?: string room_type?: string
encryption?: string
} }
export type ResolvedRoom = { export type ResolvedRoom = {

View file

@ -108,7 +108,7 @@ block body
h3.mt32.fs-category Default roles h3.mt32.fs-category Default roles
.s-card .s-card
form(method="post" action=rel("/api/default-roles") hx-post=rel("/api/default-roles") hx-sync="this:drop" hx-indicator="#add-role-loading" hx-target="#default-roles-list" hx-select="#default-roles-list" hx-swap="outerHTML")#default-roles form(method="post" action=rel("/api/default-roles") hx-post=rel("/api/default-roles") hx-indicator="#add-role-loading" hx-target="#default-roles-list" hx-select="#default-roles-list" hx-swap="outerHTML")#default-roles
input(type="hidden" name="guild_id" value=guild_id) input(type="hidden" name="guild_id" value=guild_id)
.d-flex.fw-wrap.g4 .d-flex.fw-wrap.g4
.s-tag.s-tag__md.fs-body1.s-tag__required @everyone .s-tag.s-tag__md.fs-body1.s-tag__required @everyone
@ -249,11 +249,6 @@ block body
ul.my8.ml24 ul.my8.ml24
each row in removedLinkedRooms each row in removedLinkedRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
h3.mt24 Unavailable rooms: Encryption not supported
.s-card.p0
ul.my8.ml24
each row in removedEncryptedRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
h3.mt24 Unavailable rooms: Wrong type h3.mt24 Unavailable rooms: Wrong type
.s-card.p0 .s-card.p0
ul.my8.ml24 ul.my8.ml24

View file

@ -131,9 +131,6 @@ as.router.post("/api/default-roles", defineEventHandler(async event => {
db.prepare("INSERT OR IGNORE INTO role_default (guild_id, role_id) VALUES (?, ?)").run(guildID, roleID) db.prepare("INSERT OR IGNORE INTO role_default (guild_id, role_id) VALUES (?, ?)").run(guildID, roleID)
} }
const createSpace = getCreateSpace(event)
await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request
if (getRequestHeader(event, "HX-Request")) { if (getRequestHeader(event, "HX-Request")) {
return pugSync.render(event, "fragments/default-roles-list.pug", {guild, guild_id: guildID}) return pugSync.render(event, "fragments/default-roles-list.pug", {guild, guild_id: guildID})
} else { } else {

View file

@ -123,14 +123,13 @@ function getChannelRoomsLinks(guild, rooms, roles) {
let unlinkedRooms = [...rooms] let unlinkedRooms = [...rooms]
let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id))
let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type) let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type)
let removedEncryptedRooms = dUtils.filterTo(unlinkedRooms, r => !r.encryption && !r["im.nheko.summary.encryption"])
// https://discord.com/developers/docs/topics/threads#active-archived-threads // https://discord.com/developers/docs/topics/threads#active-archived-threads
// need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name // need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name
let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/))
return { return {
linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms,
removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms, removedEncryptedRooms removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms
} }
} }

View file

@ -204,12 +204,6 @@ as.router.post("/api/link", defineEventHandler(async event => {
throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`})
} }
// Check room is not encrypted
const encryption = await api.getStateEvent(parsedBody.matrix, "m.room.encryption", "").catch(() => null)
if (encryption) {
throw createError({status: 400, message: "Bad Request", data: "Encrypted rooms are not supported for bridging. Please replace it with an unencrypted room."})
}
// Check bridge has PL 100 // Check bridge has PL 100
const {powerLevels, powers: {[utils.bot]: selfPowerLevel}} = await utils.getEffectivePower(parsedBody.matrix, [utils.bot], api) const {powerLevels, powers: {[utils.bot]: selfPowerLevel}} = await utils.getEffectivePower(parsedBody.matrix, [utils.bot], api)
if (selfPowerLevel < (powerLevels?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"}) if (selfPowerLevel < (powerLevels?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"})

View file

@ -435,47 +435,6 @@ test("web link room: check that bridge can join room (uses via for join attempt)
t.equal(called, 2) t.equal(called, 2)
}) })
test("web link room: check that room is not encrypted", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
discord: "665310973967597573",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async joinRoom(roomID) {
called++
return roomID
},
async *generateFullHierarchy(spaceID) {
called++
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
children_state: [],
guest_can_join: false,
num_joined_members: 2
}
/* c8 ignore next */
},
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
if (type === "m.room.encryption" && key === "") {
return {algorithm: "m.megolm.v1.aes-sha2"}
}
throw new Error("Unknown state event")
}
}
}))
t.equal(error.data, "Encrypted rooms are not supported for bridging. Please replace it with an unencrypted room.")
t.equal(called, 3)
})
test("web link room: check that bridge has PL 100 in target room", async t => { test("web link room: check that bridge has PL 100 in target room", async t => {
let called = 0 let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", { const [error] = await tryToCatch(() => router.test("post", "/api/link", {
@ -506,10 +465,9 @@ test("web link room: check that bridge has PL 100 in target room", async t => {
async getStateEvent(roomID, type, key) { async getStateEvent(roomID, type, key) {
called++ called++
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
if (type === "m.room.power_levels" && key === "") { t.equal(type, "m.room.power_levels")
return {users_default: 50} t.equal(key, "")
} return {users_default: 50}
throw new Error("Unknown state event")
}, },
async getStateEventOuter(roomID, type, key) { async getStateEventOuter(roomID, type, key) {
called++ called++
@ -531,7 +489,7 @@ test("web link room: check that bridge has PL 100 in target room", async t => {
} }
})) }))
t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room") t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room")
t.equal(called, 5) t.equal(called, 4)
}) })
test("web link room: successfully calls createRoom", async t => { test("web link room: successfully calls createRoom", async t => {

View file

@ -5067,141 +5067,6 @@ module.exports = {
pinned: false, pinned: false,
mention_everyone: false, mention_everyone: false,
tts: false tts: false
},
four_images: {
type: 0,
content: "",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2026-03-12T18:00:50.737000+00:00",
edited_timestamp: null,
flags: 16384,
components: [],
id: "1481713598278533241",
channel_id: "687028734322147344",
author: {
id: "112760500130975744",
username: "minimus",
avatar: "a_a354b9eaff512485b49c82b13691b941",
discriminator: "0",
public_flags: 512,
flags: 512,
banner: null,
accent_color: null,
global_name: "minimus",
avatar_decoration_data: null,
collectibles: null,
display_name_styles: { font_id: 11, effect_id: 5, colors: [ 6106655 ] },
banner_color: null,
clan: null,
primary_guild: null
},
pinned: false,
mention_everyone: false,
tts: false,
message_reference: {
type: 1,
channel_id: "637339857118822430",
message_id: "1481696763483258891",
guild_id: "408573045540651009"
},
message_snapshots: [
{
message: {
type: 0,
content: "https://fixupx.com/i/status/2032003668787020046",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [
{
type: "rich",
url: "https://fixupx.com/i/status/2032003668787020046",
description: "4chan owner Hiroyuki, Evangelion director Hideaki Anno and GACKT to participate in “humanitys last non\\-AI made social network”\n" +
"\n" +
"[automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/](https://automaton-media.com/en/news/4chan-owner-hiroyuki-evangelion-director-hideaki-anno-and-gackt-to-participate-in-humanitys-last-non-ai-made-social-network/)\n" +
"\n" +
"**[💬](https://x.com/intent/tweet?in_reply_to=2032003668787020046) 36[🔁](https://x.com/intent/retweet?tweet_id=2032003668787020046) 212[❤](https://x.com/intent/like?tweet_id=2032003668787020046) 3\\.0K 👁 131\\.7K**",
color: 6513919,
timestamp: "2026-03-12T08:00:02+00:00",
author: {
name: "AUTOMATON WEST (@AUTOMATON_ENG)",
url: "https://x.com/AUTOMATON_ENG/status/2032003668787020046",
icon_url: "https://pbs.twimg.com/profile_images/1353559126693961729/pz-WVnDc_200x200.jpg",
proxy_icon_url: "https://images-ext-1.discordapp.net/external/1OzGhjvZTRstTxM38_7pqHXlmdbMddqh1F8R0-WrKqw/https/pbs.twimg.com/profile_images/1353559126693961729/pz-WVnDc_200x200.jpg"
},
image: {
url: "https://pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg?name=orig",
proxy_url: "https://images-ext-1.discordapp.net/external/NkNgp2SyY1OCH9IdS8hqsUqbnbrp3A9oLNwYusVVCVQ/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUyf6bQAM3yts.jpg",
width: 872,
height: 886,
content_type: "image/jpeg",
placeholder: "6vcFFwL6R3lye2V3l1mIl5l3WPN5FZ8H",
placeholder_version: 1,
flags: 0
},
footer: {
text: "FixupX",
icon_url: "https://assets.fxembed.com/logos/fixupx64.png",
proxy_icon_url: "https://images-ext-1.discordapp.net/external/LwQ70Uiqfu0OCN4ZbA4f482TGCgQa-xGsnUFYfhIgYA/https/assets.fxembed.com/logos/fixupx64.png"
},
content_scan_version: 4
},
{
type: "rich",
url: "https://fixupx.com/i/status/2032003668787020046",
image: {
url: "https://pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg?name=orig",
proxy_url: "https://images-ext-1.discordapp.net/external/Rquh1ec-tG9hMqdHqIVSphO7zf5B5Fg_7yTWhCjlsek/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUgxybQAE4FtJ.jpg",
width: 1114,
height: 991,
content_type: "image/jpeg",
placeholder: "JQgKDoL3epZ8ZIdnlmmHZ4d4CIGmUEc=",
placeholder_version: 1,
flags: 0
},
content_scan_version: 4
},
{
type: "rich",
url: "https://fixupx.com/i/status/2032003668787020046",
image: {
url: "https://pbs.twimg.com/media/HDMUrPobgAAeb90.jpg?name=orig",
proxy_url: "https://images-ext-1.discordapp.net/external/XrkhHNH3CvlZYvjkdykVnf-_xdz6HWX8uwesoAwwSfY/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUrPobgAAeb90.jpg",
width: 944,
height: 954,
content_type: "image/jpeg",
placeholder: "m/cJDwCbV0mfaoZzlihqeXdqCVN9A6oD",
placeholder_version: 1,
flags: 0
},
content_scan_version: 4
},
{
type: "rich",
url: "https://fixupx.com/i/status/2032003668787020046",
image: {
url: "https://pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg?name=orig",
proxy_url: "https://images-ext-1.discordapp.net/external/lO-5hBMU9bGH13Ax9xum2T2Mg0ATdv0b6BEx_VeVi80/%3Fname%3Dorig/https/pbs.twimg.com/media/HDMUuy5bgAAInj5.jpg",
width: 1200,
height: 630,
content_type: "image/jpeg",
placeholder: "tfcJDIK3mIl1eIiPdY23dX9b9w==",
placeholder_version: 1,
flags: 0
},
content_scan_version: 4
}
],
timestamp: "2026-03-12T16:53:57.009000+00:00",
edited_timestamp: null,
flags: 0,
components: []
}
}
]
} }
}, },
message_with_components: { message_with_components: {