, I respond: Stop drinking paint. (No), I respond: Stop drinking paint. (No), I respond: Stop drinking paint. (No), I respond: Stop drinking paint. (No)In reply to Extremity' - + '
Image
↪️ ${mxid ? tag`${username}` : username} used /${interaction.name}${thinkingText}`
+ }
+}
+
/**
* @param {DiscordTypes.APIMessage} message
* @param {DiscordTypes.APIGuild} guild
@@ -210,6 +273,7 @@ async function attachmentToEvent(mentions, attachment) {
* @returns {Promise<{$type: string, $sender?: string, [x: string]: any}[]>}
*/
async function messageToEvent(message, guild, options = {}, di) {
+ message = structuredClone(message)
const events = []
/* c8 ignore next 7 */
@@ -221,6 +285,38 @@ async function messageToEvent(message, guild, options = {}, di) {
return []
}
+ if (message.type === DiscordTypes.MessageType.PollResult) {
+ const pollMessageID = message.message_reference?.message_id
+ if (!pollMessageID) return []
+ const event_id = select("event_message", "event_id", {message_id: pollMessageID}).pluck().get()
+ const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
+ const pollQuestionText = select("poll", "question_text", {message_id: pollMessageID}).pluck().get()
+ if (!event_id || !roomID || !pollQuestionText) return [] // drop it if the corresponding poll start was not bridged
+
+ const rep = new mxUtils.MatrixStringBuilder()
+ rep.addLine(`The poll ${pollQuestionText} has closed.`, tag`The poll ${pollQuestionText} has closed.`)
+
+ const {messageString} = pollResponses.getCombinedResults(pollMessageID, true) // poll results have already been double-checked before this point, so these totals will be accurate
+ rep.addLine(markdown.toHTML(messageString, {discordOnly: true, escapeHTML: false}), markdown.toHTML(messageString, {}))
+
+ const {body, formatted_body} = rep.get()
+
+ return [{
+ $type: "org.matrix.msc3381.poll.end",
+ "m.relates_to": {
+ rel_type: "m.reference",
+ event_id
+ },
+ "org.matrix.msc3381.poll.end": {},
+ "org.matrix.msc1767.text": body,
+ "org.matrix.msc1767.html": formatted_body,
+ body: body,
+ format: "org.matrix.custom.html",
+ formatted_body: formatted_body,
+ msgtype: "m.text"
+ }]
+ }
+
if (message.type === DiscordTypes.MessageType.ThreadStarterMessage) {
// This is the message that appears at the top of a thread when the thread was based off an existing message.
// It's just a message reference, no content.
@@ -239,14 +335,8 @@ async function messageToEvent(message, guild, options = {}, di) {
}
const interaction = message.interaction_metadata || message.interaction
- if (message.type === DiscordTypes.MessageType.ChatInputCommand && interaction && "name" in interaction) {
- // Commands are sent by the responding bot. Need to attach the metadata of the person using the command at the top.
- let content = message.content
- if (content) content = `\n${content}`
- else if ((message.flags || 0) & DiscordTypes.MessageFlags.Loading) content = " — interaction loading..."
- content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${content}`
- message = {...message, content} // editToChanges reuses the object so we can't mutate it. have to clone it
- }
+ const isInteraction = message.type === DiscordTypes.MessageType.ChatInputCommand && !!interaction && "name" in interaction
+ const isThinkingInteraction = isInteraction && !!((message.flags || 0) & DiscordTypes.MessageFlags.Loading)
/**
@type {{room?: boolean, user_ids?: string[]}}
@@ -265,8 +355,9 @@ async function messageToEvent(message, guild, options = {}, di) {
- So make sure we don't do anything in this case.
*/
const mentions = {}
- /** @type {{event_id: string, room_id: string, source: number}?} */
+ /** @type {{event_id: string, room_id: string, source: number, channel_id: string}?} */
let repliedToEventRow = null
+ let repliedToEventInDifferentRoom = false
let repliedToUnknownEvent = false
let repliedToEventSenderMxid = null
@@ -280,9 +371,9 @@ async function messageToEvent(message, guild, options = {}, di) {
// Mentions scenarios 1 and 2, part A. i.e. translate relevant message.mentions to m.mentions
// (Still need to do scenarios 1 and 2 part B, and scenario 3.)
if (message.type === DiscordTypes.MessageType.Reply && message.message_reference?.message_id) {
- const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(message.message_reference.message_id)
- if (row) {
- repliedToEventRow = row
+ const row = await getHistoricalEventRow(message.message_reference?.message_id)
+ if (row && "event_id" in row) {
+ repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
} else if (message.referenced_message) {
repliedToUnknownEvent = true
}
@@ -294,8 +385,8 @@ async function messageToEvent(message, guild, options = {}, di) {
assert(message.embeds[0].description)
const match = message.embeds[0].description.match(/\/channels\/[0-9]*\/[0-9]*\/([0-9]{2,})/)
if (match) {
- const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(match[1])
- if (row) {
+ const row = await getHistoricalEventRow(match[1])
+ if (row && "event_id" in row) {
/*
we generate a partial referenced_message based on what PK provided. we don't need everything, since this will only be used for further message-to-event converting.
the following properties are necessary:
@@ -313,7 +404,7 @@ async function messageToEvent(message, guild, options = {}, di) {
}
}
message.embeds.shift()
- repliedToEventRow = row
+ repliedToEventRow = Object.assign(row, {channel_id: row.reference_channel_id})
}
}
}
@@ -340,6 +431,34 @@ async function messageToEvent(message, guild, options = {}, di) {
return promise
}
+ /**
+ * @param {string} messageID
+ * @param {string} [timestampChannelID]
+ */
+ async function getHistoricalEventRow(messageID, timestampChannelID) {
+ /** @type {{room_id: string} | {event_id: string, room_id: string, reference_channel_id: string, source: number} | null | undefined} */
+ let row = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index")
+ .select("event_id", "room_id", "reference_channel_id", "source").where({message_id: messageID}).and("ORDER BY part ASC").get()
+ if (!row && timestampChannelID) {
+ const ts = dUtils.snowflakeToTimestampExact(messageID)
+ const oldestRow = from("historical_channel_room").selectUnsafe("max(upgraded_timestamp)", "room_id")
+ .where({reference_channel_id: timestampChannelID}).and("and upgraded_timestamp < ?").get(ts)
+ if (oldestRow?.room_id) {
+ row = {room_id: oldestRow.room_id}
+ try {
+ const {event_id} = await di.api.getEventForTimestamp(oldestRow.room_id, ts)
+ row = {
+ event_id,
+ room_id: oldestRow.room_id,
+ reference_channel_id: oldestRow.reference_channel_id,
+ source: 1
+ }
+ } catch (e) {}
+ }
+ }
+ return row
+ }
+
/**
* Translate Discord message links to Matrix event links.
* If OOYE has handled this message in the past, this is an instant database lookup.
@@ -351,27 +470,13 @@ async function messageToEvent(message, guild, options = {}, di) {
for (const match of [...content.matchAll(/https:\/\/(?:ptb\.|canary\.|www\.)?discord(?:app)?\.com\/channels\/[0-9]+\/([0-9]+)\/([0-9]+)/g)]) {
assert(typeof match.index === "number")
const [_, channelID, messageID] = match
- let result
-
- const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
- if (roomID) {
- const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
- const via = await getViaServersMemo(roomID)
- if (eventID && roomID) {
- result = `https://matrix.to/#/${roomID}/${eventID}?${via}`
- } else {
- const ts = dUtils.snowflakeToTimestampExact(messageID)
- try {
- const {event_id} = await di.api.getEventForTimestamp(roomID, ts)
- result = `https://matrix.to/#/${roomID}/${event_id}?${via}`
- } catch (e) {
- // M_NOT_FOUND: Unable to find event from In reply to ${repliedToUserHtml}` - + `
${repliedToHtml}
In reply to ${dateDisplay} from ${repliedToDisplayName}:` - + `` - + html - body = (`In reply to ${dateDisplay}:\n${repliedToDisplayName}: ` - + repliedToBody).split("\n").map(line => "> " + line).join("\n") - + "\n\n" + body + if ((repliedToEventRow || repliedToUnknownEvent) && options.includeReplyFallback !== false && events.length === 0) { + const latestRoomID = repliedToEventRow ? select("channel_room", "room_id", {channel_id: repliedToEventRow.channel_id}).pluck().get() : null + if (latestRoomID !== repliedToEventRow?.room_id) repliedToEventInDifferentRoom = true + + // check that condition 1 or 2 is met + if (repliedToEventInDifferentRoom || repliedToUnknownEvent) { + let referenced = message.referenced_message + if (!referenced) { // backend couldn't be bothered to dereference the message, have to do it ourselves + assert(message.message_reference?.message_id) + referenced = await discord.snow.channel.getChannelMessage(message.message_reference.channel_id, message.message_reference.message_id) + } + + // Username + let repliedToDisplayName + let repliedToUserHtml + if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) { + const match = repliedToEventSenderMxid.match(/^@([^:]*)/) + assert(match) + repliedToDisplayName = referenced.author.username || match[1] || "a Matrix user" // grab the localpart as the display name, whatever + repliedToUserHtml = `${repliedToDisplayName}` + } else { + repliedToDisplayName = referenced.author.global_name || referenced.author.username || "a Discord user" + repliedToUserHtml = repliedToDisplayName + } + + // Content + let repliedToContent = referenced.content + if (repliedToContent?.match(/^(-# )?> (-# )?quote or -#smalltext >quote. Match until the end of the line. + // ┆ ┆┌─B─┐ There may be up to 2 reply rep lines in a row if it was created in the old format. Match all lines. + repliedToContent = repliedToContent.replace(/^((-# )?> .*\n){1,2}/, "") + } + if (repliedToContent == "") repliedToContent = "[Media]" + const {body: repliedToBody, html: repliedToHtml} = await transformContent(repliedToContent) + + // Now branch on condition 1 or 2 for a different kind of fallback + if (repliedToEventRow) { + html = `
${repliedToHtml}
In reply to ${repliedToUserHtml}` + + `` + + html + body = `${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") // scenario 1 part B for mentions + + "\n\n" + body + } else { // repliedToUnknownEvent + const dateDisplay = dUtils.howOldUnbridgedMessage(referenced.timestamp, message.timestamp) + html = `
${repliedToHtml}
In reply to ${dateDisplay} from ${repliedToDisplayName}:` + + `` + + html + body = `In reply to ${dateDisplay}:\n${repliedToDisplayName}: ${repliedToBody}`.split("\n").map(line => "> " + line).join("\n") + + "\n\n" + body + } } } + if (isInteraction && !isThinkingInteraction && events.length === 0) { + const formattedInteraction = getFormattedInteraction(interaction, false) + body = `${formattedInteraction.body}\n${body}` + html = `${formattedInteraction.html}${html}` + } + const newTextMessageEvent = { $type: "m.room.message", "m.mentions": mentions, msgtype, - body: body - } - - const isPlaintext = body === html - - if (!isPlaintext || options.alwaysReturnFormattedBody) { - Object.assign(newTextMessageEvent, { - format: "org.matrix.custom.html", - formatted_body: html - }) + body: body, + format: "org.matrix.custom.html", + formatted_body: html } events.push(newTextMessageEvent) @@ -541,24 +666,35 @@ async function messageToEvent(message, guild, options = {}, di) { message.content = `added a new emoji, ${message.content} :${name}:` } + // Send Klipy GIFs in customised form + let isKlipyGIF = false + let isOnlyKlipyGIF = false + if (message.embeds?.length === 1 && message.embeds[0].provider?.name === "Klipy" && message.embeds[0].video?.url) { + isKlipyGIF = true + if (message.content.match(/^https?:\/\/klipy\.com[^ \n]+$/)) { + isOnlyKlipyGIF = true + } + } + // Forwarded content appears first - if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_snapshots?.length) { + if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_reference.message_id && message.message_snapshots?.length) { // Forwarded notice - const eventID = select("event_message", "event_id", {message_id: message.message_reference.message_id}).pluck().get() + const row = await getHistoricalEventRow(message.message_reference.message_id, message.message_reference.channel_id) const room = select("channel_room", ["room_id", "name", "nick"], {channel_id: message.message_reference.channel_id}).get() const forwardedNotice = new mxUtils.MatrixStringBuilder() if (room) { const roomName = room && (room.nick || room.name) - const via = await getViaServersMemo(room.room_id) - if (eventID) { + if (row && "event_id" in row) { + const via = await getViaServersMemo(row.room_id) forwardedNotice.addLine( `[🔀 Forwarded from #${roomName}]`, - tag`🔀 Forwarded from ${roomName}` + tag`🔀 Forwarded from ${roomName} [jump to event]` ) } else { + const via = await getViaServersMemo(room.room_id) forwardedNotice.addLine( `[🔀 Forwarded from #${roomName}]`, - tag`🔀 Forwarded from ${roomName}` + tag`🔀 Forwarded from ${roomName} [jump to room]` ) } } else { @@ -575,7 +711,6 @@ async function messageToEvent(message, guild, options = {}, di) { // Indent for (const event of forwardedEvents) { if (["m.text", "m.notice"].includes(event.msgtype)) { - event.msgtype = "m.notice" event.body = event.body.split("\n").map(l => "» " + l).join("\n") event.formatted_body = `
${repliedToHtml}
${event.formatted_body}` } @@ -592,26 +727,42 @@ async function messageToEvent(message, guild, options = {}, di) { events.push(...forwardedEvents) } + if (isThinkingInteraction) { + const formattedInteraction = getFormattedInteraction(interaction, true) + await addTextEvent(formattedInteraction.body, formattedInteraction.html, "m.notice") + } + // Then text content - if (message.content) { + 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. - const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)] - if (options.scanTextForMentions !== false && matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) { - const writtenMentionsText = matches.map(m => m[1].toLowerCase()) - const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() - assert(roomID) - const {joined} = await di.api.getJoinedMembers(roomID) - for (const [mxid, member] of Object.entries(joined)) { - if (!userRegex.some(rx => mxid.match(rx))) { - const localpart = mxid.match(/@([^:]*)/) - assert(localpart) - const displayName = member.display_name || localpart[1] - if (writtenMentionsText.includes(localpart[1].toLowerCase()) || writtenMentionsText.includes(displayName.toLowerCase())) addMention(mxid) + 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 } } } - const {body, html} = await transformContent(message.content) + // Scan the content for emojihax and replace them with real emojis + 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}>` + }) + + const {body, html} = await transformContent(content) await addTextEvent(body, html, msgtype) } @@ -660,8 +811,125 @@ async function messageToEvent(message, guild, options = {}, di) { // Then attachments if (message.attachments) { - const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions))) - events.push(...attachmentEvents) + const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment))) + + // 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. + 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 + "
${formatted_body}` + if (stack.msb.body) stack.msb.body += "\n\n" + stack.msb.add(body, formatted_body) + } + else if (component.type === DiscordTypes.ComponentType.Section) { + // May contain text display, possibly more in the future + // Accessory may be button or thumbnail + stack.bump() + for (const innerComponent of component.components) { + await processComponent(innerComponent) + } + if (component.accessory) { + stack.bump() + await processComponent(component.accessory) + const {body, formatted_body} = stack.shift().get() + stack.msb.addLine(body, formatted_body) + } + const {body, formatted_body} = stack.shift().get() + stack.msb.addParagraph(body, formatted_body) + } + else if (component.type === DiscordTypes.ComponentType.ActionRow) { + const linkButtons = component.components.filter(c => c.type === DiscordTypes.ComponentType.Button && c.style === DiscordTypes.ButtonStyle.Link) + if (linkButtons.length) { + stack.msb.addLine("") + for (const linkButton of linkButtons) { + await processComponent(linkButton) + } + } + } + // Components that can only be inside things + else if (component.type === DiscordTypes.ComponentType.Thumbnail) { + // May only be a section accessory + stack.msb.add(`🖼️ ${component.media.url}`, tag`🖼️ ${component.media.url}`) + } + else if (component.type === DiscordTypes.ComponentType.Button) { + // May only be a section accessory or in an action row (up to 5) + if (component.style === DiscordTypes.ButtonStyle.Link) { + if (component.label) { + stack.msb.add(`[${component.label} ${component.url}] `, tag`${component.label} `) + } else { + stack.msb.add(component.url) + } + } + } + + // Not handling file upload or label because they are modal-only components + } + + for (const component of message.components) { + await processComponent(component) + } + + const {body, formatted_body} = stack.msb.get() + if (body.trim().length) { + await addTextEvent(body, formatted_body, "m.text") + } + } + + // Then polls + if (message.poll) { + const pollEvent = await pollToEvent(message.poll) + events.push(pollEvent) } // Then embeds @@ -675,13 +943,43 @@ async function messageToEvent(message, guild, options = {}, di) { continue // Matrix's own URL previews are fine for images. } + if (embed.type === "video" && embed.video?.url && !embed.title && message.content.includes(embed.video.url)) { + continue // Doesn't add extra information and the direct video URL is already there. + } + + if (embed.type === "poll_result") { + // The code here is only for the message to be bridged to Matrix. Dealing with the Discord-side updates is in d2m/actions/poll-end.js. + } + if (embed.url?.startsWith("https://discord.com/")) { continue // If discord creates an embed preview for a discord channel link, don't copy that embed } + if (embed.url && spoilers.some(sp => sp.match(/\bhttps?:\/\/[a-z]/))) { + // If the original message had spoilered URLs, don't generate any embeds for links. + // This logic is the same as the Discord desktop client. It doesn't match specific embeds to specific spoilered text, it's all or nothing. + // It's not easy to do much better because posting a link like youtu.be generates an embed.url with youtube.com/watch, so you can't match up the text without making at least that a special case. + continue + } + // 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() + if (isKlipyGIF) { + assert(embed.video?.url) + rep.add("[GIF] ", "➿ ") + if (embed.title) { + rep.add(`${embed.title} ${embed.video.url}`, tag`${embed.title}`) + } else { + rep.add(embed.video.url) + } + + let {body, formatted_body: html} = rep.get() + html = `
${html}` + await addTextEvent(body, html, "m.text") + continue + } + // Provider if (embed.provider?.name && embed.provider.name !== "Tenor") { if (embed.provider.url) { @@ -778,7 +1076,7 @@ async function messageToEvent(message, guild, options = {}, di) { } // Rich replies - if (repliedToEventRow) { + if (repliedToEventRow && !repliedToEventInDifferentRoom) { Object.assign(events[0], { "m.relates_to": { "m.in_reply_to": { @@ -788,6 +1086,16 @@ async function messageToEvent(message, guild, options = {}, di) { }) } + // Strip formatted_body where equivalent to body + if (!options.alwaysReturnFormattedBody) { + for (const event of events) { + if (event.$type === "m.room.message" && "msgtype" in event && ["m.text", "m.notice"].includes(event.msgtype) && event.body === event.formatted_body) { + delete event.format + delete event.formatted_body + } + } + } + return events } diff --git a/src/d2m/converters/message-to-event.test.components.js b/src/d2m/converters/message-to-event.test.components.js new file mode 100644 index 0000000..7d875a6 --- /dev/null +++ b/src/d2m/converters/message-to-event.test.components.js @@ -0,0 +1,79 @@ +const {test} = require("supertape") +const {messageToEvent} = require("./message-to-event") +const data = require("../../../test/data") + +test("message2event components: pk question mark output", async t => { + const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + body: + "| ### Lillith (INX)" + + "\n| " + + "\n| **Display name:** Lillith (she/her)" + + "\n| **Pronouns:** She/Her" + + "\n| **Message count:** 3091" + + "\n| 🖼️ https://files.inx.moe/p/cdn/lillith.webp" + + "\n| " + + "\n| ----" + + "\n| " + + "\n| **Proxy tags:**" + + "\n| ``l;text``" + + "\n| ``l:text``" + + "\n| ``l.text``" + + "\n| ``textl.``" + + "\n| ``textl;``" + + "\n| ``textl:``" + + "\n" + + "\n-# System ID: `xffgnx` ∙ Member ID: `pphhoh`" + + "\n-# Created: 2025-12-31 03:16:45 UTC" + + "\n[View on dashboard https://dash.pluralkit.me/profile/m/pphhoh] " + + "\n" + + "\n----" + + "\n" + + "\n| **System:** INX (`xffgnx`)" + + "\n| **Member:** Lillith (`pphhoh`)" + + "\n| **Sent by:** infinidoge1337 (@unknown-user:)" + + "\n| " + + "\n| **Account Roles (7)**" + + "\n| §b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping" + + "\n| 🖼️ https://files.inx.moe/p/cdn/lillith.webp" + + "\n| " + + "\n| ----" + + "\n| " + + "\n| Same hat" + + "\n| 🖼️ Image: https://bridge.example.org/download/discordcdn/934955898965729280/1466556006527012987/image.png" + + "\n" + + "\n-# Original Message ID: 1466556003645657118 ·
" + + "" + + "Lillith (INX)
" + + "Display name: Lillith (she/her)" + + "
" + + `🖼️ https://files.inx.moe/p/cdn/lillith.webp` + + "
Pronouns: She/Her" + + "
Message count: 3091
" + + "Proxy tags:" + + "
l;text" + + "l:text" + + "l.text" + + "textl." + + "textl;" + + "textl:
System ID: xffgnx ∙ Member ID: pphhoh
"
+ + "Created: 2025-12-31 03:16:45 UTC
` + + "System: INX (
" + + `🖼️ https://files.inx.moe/p/cdn/lillith.webp` + + "xffgnx)" + + "
Member: Lillith (pphhoh)" + + "
Sent by: infinidoge1337 (@unknown-user:)" + + "
Account Roles (7)" + + "
§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping
" + + "Same hat
" + + `🖼️ Image: image.png
Original Message ID: 1466556003645657118 · <t:1769724599:f>
", + "m.mentions": {}, + msgtype: "m.text", + }]) +}) diff --git a/src/d2m/converters/message-to-event.embeds.test.js b/src/d2m/converters/message-to-event.test.embeds.js similarity index 83% rename from src/d2m/converters/message-to-event.embeds.test.js rename to src/d2m/converters/message-to-event.test.embeds.js index ed165c6..259aa66 100644 --- a/src/d2m/converters/message-to-event.embeds.test.js +++ b/src/d2m/converters/message-to-event.test.embeds.js @@ -1,26 +1,34 @@ const {test} = require("supertape") const {messageToEvent} = require("./message-to-event") const data = require("../../../test/data") +const {mockGetEffectivePower} = require("../../matrix/utils.test") const {db} = require("../../passthrough") +test("message2event embeds: interaction loading", async t => { + const events = await messageToEvent(data.interaction_message.thinking_interaction, data.guild.general, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + body: "↪️ Brad used `/stats` — interaction loading...", + format: "org.matrix.custom.html", + formatted_body: "↪️ Brad used /stats — interaction loading...",
+ "m.mentions": {},
+ msgtype: "m.notice",
+ }])
+})
+
test("message2event embeds: nothing but a field", async t => {
const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {})
t.deepEqual(events, [{
- $type: "m.room.message",
- body: "> ↪️ @papiophidian: used `/stats`",
- format: "org.matrix.custom.html",
- formatted_body: "↪️ @papiophidian used /stats",
- "m.mentions": {},
- msgtype: "m.text",
- }, {
$type: "m.room.message",
"m.mentions": {},
msgtype: "m.notice",
- body: "| ### Amanda 🎵#2192 :online:"
+ body: "↪️ PapiOphidian used `/stats`"
+ + "\n| ### Amanda 🎵#2192 :online:"
+ "\n| willow tree, branch 0"
+ "\n| **❯ Uptime:**\n| 3m 55s\n| **❯ Memory:**\n| 64.45MB",
format: "org.matrix.custom.html",
- formatted_body: '", "m.mentions": {} }]) - t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") + t.equal(called, 1, "should call getJoinedMembers once") }) test("message2event embeds: crazy html is all escaped", async t => { @@ -153,18 +151,13 @@ test("message2event embeds: crazy html is all escaped", async t => { test("message2event embeds: title without url", async t => { const events = await messageToEvent(data.message_with_embeds.title_without_url, data.guild.general) t.deepEqual(events, [{ - $type: "m.room.message", - body: "> ↪️ @papiophidian: used `/stats`", - format: "org.matrix.custom.html", - formatted_body: "Amanda 🎵#2192
' + formatted_body: '
↪️ PapiOphidian used' + + '/stats' @@ -86,17 +94,7 @@ test("message2event embeds: blockquote in embed", async t => { let called = 0 const events = await messageToEvent(data.message_with_embeds.blockquote_in_embed, data.guild.general, {}, { api: { - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return { - users: { - "@_ooye_bot:cadence.moe": 100 - } - } - }, + getEffectivePower: mockGetEffectivePower(), async getJoinedMembers(roomID) { called++ t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") @@ -124,7 +122,7 @@ test("message2event embeds: blockquote in embed", async t => { formatted_body: "Amanda 🎵#2192
' + '
willow tree, branch 0' + '
❯ Uptime:
3m 55s' + '
❯ Memory:
64.45MBreply draft
The following is a message composed via consensus of the Stinker Council.
For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.
Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.
There will be no further communication.
↪️ @papiophidian used /stats",
- "m.mentions": {},
- msgtype: "m.text",
- }, {
$type: "m.room.message",
msgtype: "m.notice",
- body: "| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
+ body: "↪️ PapiOphidian used `/stats`"
+ + "\n| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
format: "org.matrix.custom.html",
- formatted_body: ``, + formatted_body: 'Hi, I'm Amanda!
I condone pirating music!
↪️ PapiOphidian used /stats'
+ + ``, "m.mentions": {} }]) }) @@ -172,18 +165,13 @@ test("message2event embeds: title without url", async t => { test("message2event embeds: url without title", async t => { const events = await messageToEvent(data.message_with_embeds.url_without_title, data.guild.general) t.deepEqual(events, [{ - $type: "m.room.message", - body: "> ↪️ @papiophidian: used `/stats`", - format: "org.matrix.custom.html", - formatted_body: "Hi, I'm Amanda!
I condone pirating music!
↪️ @papiophidian used /stats",
- "m.mentions": {},
- msgtype: "m.text",
- }, {
$type: "m.room.message",
msgtype: "m.notice",
- body: "| I condone pirating music!",
+ body: "↪️ PapiOphidian used `/stats`"
+ + "\n| I condone pirating music!",
format: "org.matrix.custom.html",
- formatted_body: ``, + formatted_body: 'I condone pirating music!
↪️ PapiOphidian used /stats'
+ + ``, "m.mentions": {} }]) }) @@ -191,18 +179,13 @@ test("message2event embeds: url without title", async t => { test("message2event embeds: author without url", async t => { const events = await messageToEvent(data.message_with_embeds.author_without_url, data.guild.general) t.deepEqual(events, [{ - $type: "m.room.message", - body: "> ↪️ @papiophidian: used `/stats`", - format: "org.matrix.custom.html", - formatted_body: "I condone pirating music!
↪️ @papiophidian used /stats",
- "m.mentions": {},
- msgtype: "m.text",
- }, {
$type: "m.room.message",
msgtype: "m.notice",
- body: "| ## Amanda\n| \n| I condone pirating music!",
+ body: "↪️ PapiOphidian used `/stats`"
+ + "\n| ## Amanda\n| \n| I condone pirating music!",
format: "org.matrix.custom.html",
- formatted_body: ``, + formatted_body: 'Amanda
I condone pirating music!
↪️ PapiOphidian used /stats'
+ + ``, "m.mentions": {} }]) }) @@ -210,18 +193,13 @@ test("message2event embeds: author without url", async t => { test("message2event embeds: author url without name", async t => { const events = await messageToEvent(data.message_with_embeds.author_url_without_name, data.guild.general) t.deepEqual(events, [{ - $type: "m.room.message", - body: "> ↪️ @papiophidian: used `/stats`", - format: "org.matrix.custom.html", - formatted_body: "Amanda
I condone pirating music!
↪️ @papiophidian used /stats",
- "m.mentions": {},
- msgtype: "m.text",
- }, {
$type: "m.room.message",
msgtype: "m.notice",
- body: "| I condone pirating music!",
+ body: "↪️ PapiOphidian used `/stats`"
+ + "\n| I condone pirating music!",
format: "org.matrix.custom.html",
- formatted_body: ``, + formatted_body: 'I condone pirating music!
↪️ PapiOphidian used /stats'
+ + ``, "m.mentions": {} }]) }) @@ -321,6 +299,21 @@ test("message2event embeds: youtube video", async t => { }]) }) +test("message2event embeds: embed not bridged if its link was spoilered", async t => { + const events = await messageToEvent({ + ...data.message_with_embeds.youtube_video, + content: "||https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E\n\n\nJutomi I'm gonna make these sounds in your walls tonight||" + }, data.guild.general) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "[spoiler]", + format: "org.matrix.custom.html", + formatted_body: `https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_EI condone pirating music!
➿ Cute Corgi Waddle", + "m.mentions": {} + }]) +}) + test("message2event embeds: if discord creates an embed preview for a discord channel link, don't copy that embed", async t => { const events = await messageToEvent(data.message_with_embeds.discord_server_included_punctuation_bad_discord, data.guild.general, {}, { api: { - async getStateEvent(roomID, type, key) { - t.equal(roomID, "!TqlyQmifxGUggEmdBN:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return { - users: { - "@_ooye_bot:cadence.moe": 100 - } - } - }, + getEffectivePower: mockGetEffectivePower(), async getJoinedMembers(roomID) { t.equal(roomID, "!TqlyQmifxGUggEmdBN:cadence.moe") return { diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index ee4ec03..1a73aea 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -2,6 +2,7 @@ const {test} = require("supertape") const {messageToEvent} = require("./message-to-event") const {MatrixServerError} = require("../../matrix/mreq") const data = require("../../../test/data") +const {mockGetEffectivePower} = require("../../matrix/utils.test") const Ty = require("../../types") /** @@ -66,17 +67,7 @@ test("message2event: simple room mention", async t => { let called = 0 const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {}, { api: { - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return { - users: { - "@_ooye_bot:cadence.moe": 100 - } - } - }, + getEffectivePower: mockGetEffectivePower(), async getJoinedMembers(roomID) { called++ t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") @@ -97,24 +88,14 @@ test("message2event: simple room mention", async t => { format: "org.matrix.custom.html", formatted_body: '#worm-farm' }]) - t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") + t.equal(called, 1, "should call getJoinedMembers") }) test("message2event: simple room link", async t => { let called = 0 const events = await messageToEvent(data.message.simple_room_link, data.guild.general, {}, { api: { - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return { - users: { - "@_ooye_bot:cadence.moe": 100 - } - } - }, + getEffectivePower: mockGetEffectivePower(), async getJoinedMembers(roomID) { called++ t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe") @@ -135,24 +116,14 @@ test("message2event: simple room link", async t => { format: "org.matrix.custom.html", formatted_body: '#worm-farm' }]) - t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") + t.equal(called, 1, "should call getJoinedMembers once") }) test("message2event: nicked room mention", async t => { let called = 0 const events = await messageToEvent(data.message.nicked_room_mention, data.guild.general, {}, { api: { - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return { - users: { - "@_ooye_bot:cadence.moe": 100 - } - } - }, + getEffectivePower: mockGetEffectivePower(), async getJoinedMembers(roomID) { called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") @@ -173,7 +144,7 @@ test("message2event: nicked room mention", async t => { format: "org.matrix.custom.html", formatted_body: '#main' }]) - t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") + t.equal(called, 1, "should call getJoinedMembers once") }) test("message2event: unknown room mention", async t => { @@ -224,17 +195,7 @@ test("message2event: simple message link", async t => { let called = 0 const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}, { api: { - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return { - users: { - "@_ooye_bot:cadence.moe": 100 - } - } - }, + getEffectivePower: mockGetEffectivePower(), async getJoinedMembers(roomID) { called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") @@ -255,13 +216,14 @@ test("message2event: simple message link", async t => { format: "org.matrix.custom.html", formatted_body: 'https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg?via=cadence.moe&via=super.invalid' }]) - t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") + t.equal(called, 1, "should call getJoinedMembers once") }) test("message2event: message link that OOYE doesn't know about", async t => { let called = 0 const events = await messageToEvent(data.message.message_link_to_before_ooye, data.guild.general, {}, { api: { + getEffectivePower: mockGetEffectivePower(), async getEventForTimestamp(roomID, ts) { called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") @@ -270,17 +232,6 @@ test("message2event: message link that OOYE doesn't know about", async t => { origin_server_ts: 1613287812754 } }, - async getStateEvent(roomID, type, key) { // for ?via calculation - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return { - users: { - "@_ooye_bot:cadence.moe": 100 - } - } - }, async getJoinedMembers(roomID) { // for ?via calculation called++ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") @@ -303,7 +254,7 @@ test("message2event: message link that OOYE doesn't know about", async t => { formatted_body: "Me: I'll scroll up to find a certain message I'll send
In reply to Extremity' - + '
Image
In reply to cadence' - + '
so can you reply to my webhook uwu
In reply to okay 🤍 yay 🤍' - + '
@extremity you owe me $30
In reply to cadence [they]
What about them?
In reply to Ami (she/her)
let me guess they got a lot of bug reports like "empty chest with no loot?"
In reply to Ami (she/her)
let me guess they got a lot of bug reports like "empty chest with no loot?"
📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg (38 KB)` + + `
What's cooking, good looking?`, "m.mentions": {}, - msgtype: "m.notice", + msgtype: "m.text", }, { $type: "m.room.message", @@ -1194,6 +1361,7 @@ test("message2event: constructed forwarded message", async t => { test("message2event: constructed forwarded text", async t => { const events = await messageToEvent(data.message.constructed_forwarded_text, {}, {}, { api: { + getEffectivePower: mockGetEffectivePower(), async getJoinedMembers() { return { joined: { @@ -1210,10 +1378,10 @@ test("message2event: constructed forwarded text", async t => { body: "[🔀 Forwarded from #amanda-spam]" + "\n» What's cooking, good looking?", format: "org.matrix.custom.html", - formatted_body: `🔀 Forwarded from amanda-spam` + formatted_body: `🔀 Forwarded from amanda-spam [jump to room]` + `
What's cooking, good looking?`, "m.mentions": {}, - msgtype: "m.notice", + msgtype: "m.text", }, { $type: "m.room.message", @@ -1236,7 +1404,7 @@ test("message2event: don't scan forwarded messages for mentions", async t => { formatted_body: `🔀 Forwarded message` + `
If some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114`, "m.mentions": {}, - msgtype: "m.notice" + msgtype: "m.text" } ]) }) @@ -1331,6 +1499,7 @@ test("message2event: vc invite event renders embed", async t => { test("message2event: vc invite event renders embed with room link", async t => { const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { api: { + getEffectivePower: mockGetEffectivePower(), getJoinedMembers: async () => ({ joined: { "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, @@ -1380,6 +1549,7 @@ test("message2event: channel links are converted even inside lists (parser post- + "\nThis list will probably change in the future" }, data.guild.general, {}, { api: { + getEffectivePower: mockGetEffectivePower(), getJoinedMembers(roomID) { called++ t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") @@ -1411,3 +1581,226 @@ test("message2event: channel links are converted even inside lists (parser post- ]) t.equal(called, 1) }) + +test("message2event: emoji added special message", async t => { + const events = await messageToEvent(data.special_message.emoji_added) + t.deepEqual(events, [ + { + $type: "m.room.message", + msgtype: "m.emote", + body: "added a new emoji, :cx_marvelous: :cx_marvelous:", + format: "org.matrix.custom.html", + formatted_body: `added a new emoji,
In reply to Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆cross-room reply`, + "m.mentions": { + user_ids: [ + "@cadence:cadence.moe" + ] + } + } + ]) +}) + +test("message2event: forwarded message with unreferenced mention", async t => { + const events = await messageToEvent({ + type: 0, + content: "", + attachments: [], + embeds: [], + timestamp: "2026-01-20T14:14:21.281Z", + edited_timestamp: null, + flags: 16384, + components: [], + id: "1463174818823405651", + channel_id: "893634327722721290", + author: { + id: "100031256988766208", + username: "leo60228", + discriminator: "0", + avatar: "8a164f29946f23eb4f45cde71a75e5a6", + avatar_decoration_data: null, + public_flags: 768, + global_name: "leo vriska", + primary_guild: null, + collectibles: null, + display_name_styles: null + }, + bot: false, + pinned: false, + mentions: [], + mention_roles: [], + mention_everyone: false, + tts: false, + message_reference: { + type: 1, + channel_id: "937181373943382036", + message_id: "1032034158261846038", + guild_id: "936370934292549712" + }, + message_snapshots: [ + { + message: { + type: 0, + content: "<@77084495118868480>", + attachments: [ + { + id: "1463174815119704114", + filename: "2022-10-18_16-49-46.mp4", + size: 51238885, + url: "https://cdn.discordapp.com/attachments/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4?ex=6970df3c&is=696f8dbc&hm=515d3cbcc8464bdada7f4c3d9ccc8174f671cb75391ce21a46a804fcb1e4befe&", + proxy_url: "https://media.discordapp.net/attachments/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4?ex=6970df3c&is=696f8dbc&hm=515d3cbcc8464bdada7f4c3d9ccc8174f671cb75391ce21a46a804fcb1e4befe&", + width: 1920, + height: 1080, + content_type: "video/mp4", + content_scan_version: 3, + spoiler: false + } + ], + embeds: [], + timestamp: "2022-10-18T20:55:17.597Z", + edited_timestamp: null, + flags: 0, + components: [] + } + } + ] + }) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "[🔀 Forwarded message]\n» @unknown-user:\n» 🎞️ Uploaded file: https://bridge.example.org/download/discordcdn/893634327722721290/1463174815119704114/2022-10-18_16-49-46.mp4 (51 MB)", + format: "org.matrix.custom.html", + formatted_body: "🔀 Forwarded message
[Media]
@unknown-user:", + "m.mentions": {} + }]) +}) + +test("message2event: single-choice poll", async t => { + const events = await messageToEvent(data.message.poll_single_choice, data.guild.general, {}) + t.deepEqual(events, [{ + $type: "org.matrix.msc3381.poll.start", + "org.matrix.msc3381.poll.start": { + question: { + "org.matrix.msc1767.text": "only one answer allowed!", + body: "only one answer allowed!", + msgtype: "m.text" + }, + kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. + max_selections: 1, + answers: [{ + id: "1", + "org.matrix.msc1767.text": "[\ud83d\udc4d] answer one" + }, { + id: "2", + "org.matrix.msc1767.text": "[\ud83d\udc4e] answer two" + }, { + id: "3", + "org.matrix.msc1767.text": "answer three" + }] + }, + "org.matrix.msc1767.text": "only one answer allowed!\n1. [\ud83d\udc4d] answer one\n2. [\ud83d\udc4e] answer two\n3. answer three" + }]) +}) + +test("message2event: multiple-choice poll", async t => { + const events = await messageToEvent(data.message.poll_multiple_choice, data.guild.general, {}) + t.deepEqual(events, [{ + $type: "org.matrix.msc3381.poll.start", + "org.matrix.msc3381.poll.start": { + question: { + "org.matrix.msc1767.text": "more than one answer allowed", + body: "more than one answer allowed", + msgtype: "m.text" + }, + kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. + max_selections: 3, + answers: [{ + id: "1", + "org.matrix.msc1767.text": "[😭] no" + }, { + id: "2", + "org.matrix.msc1767.text": "oh no" + }, { + id: "3", + "org.matrix.msc1767.text": "oh noooooo" + }] + }, + "org.matrix.msc1767.text": "more than one answer allowed\n1. [😭] no\n2. oh no\n3. oh noooooo" + }]) +}) + +test("message2event: smalltext from regular user", async t => { + const events = await messageToEvent({ + content: "-# hmm", + author: { + bot: false + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + "m.mentions": {}, + body: "...hmm" + }]) +}) diff --git a/src/d2m/converters/message-to-event.pk.test.js b/src/d2m/converters/message-to-event.test.pk.js similarity index 72% rename from src/d2m/converters/message-to-event.pk.test.js rename to src/d2m/converters/message-to-event.test.pk.js index ce83d54..1323280 100644 --- a/src/d2m/converters/message-to-event.pk.test.js +++ b/src/d2m/converters/message-to-event.test.pk.js @@ -50,11 +50,7 @@ test("message2event: pk reply to matrix is converted to native matrix reply", as ] }, msgtype: "m.text", - body: "> cadence [they]: now for my next experiment:\n\nthis is a reply", - format: "org.matrix.custom.html", - formatted_body: '
🎞️ Uploaded file: 2022-10-18_16-49-46.mp4 (51 MB)
In reply to cadence [they]
' - + "now for my next experiment:
In reply to wing
' - + "some text
In reply to Ampflower 🌺
' - + "[Media]
${formattedBody}
` - this.formattedBody += formattedBody - } - return this - } - - get() { - return { - msgtype: "m.text", - body: this.body, - format: "org.matrix.custom.html", - formatted_body: this.formattedBody - } - } -} - -/** - * Context: Room IDs are not routable on their own. Room permalinks need a list of servers to try. The client is responsible for coming up with a list of servers. - * ASSUMPTION 1: The bridge bot is a member of the target room and can therefore access its power levels and member list for calculation. - * ASSUMPTION 2: Because the bridge bot is a member of the target room, the target room is bridged. - * https://spec.matrix.org/v1.9/appendices/#routing - * https://gitdab.com/cadence/out-of-your-element/issues/11 - * @param {string} roomID - * @param {{[K in "getStateEvent" | "getJoinedMembers"]: import("../../matrix/api")[K]}} api - */ -async function getViaServers(roomID, api) { - const candidates = [] - const {joined} = await api.getJoinedMembers(roomID) - // Candidate 0: The bot's own server name - candidates.push(reg.ooye.server_name) - // Candidate 1: Highest joined non-sim non-bot power level user in the room - // https://github.com/matrix-org/matrix-react-sdk/blob/552c65db98b59406fb49562e537a2721c8505517/src/utils/permalinks/Permalinks.ts#L172 - try { - /** @type {{users?: {[mxid: string]: number}}} */ - const powerLevels = await api.getStateEvent(roomID, "m.room.power_levels", "") - if (powerLevels.users) { - const sorted = Object.entries(powerLevels.users).sort((a, b) => b[1] - a[1]) // Highest... - for (const power of sorted) { - const mxid = power[0] - if (!(mxid in joined)) continue // joined... - if (userRegex.some(r => mxid.match(r))) continue // non-sim non-bot... - const match = mxid.match(/:(.*)/) - assert(match) - if (!candidates.includes(match[1])) { - candidates.push(match[1]) - break - } - } - } - } catch (e) { - // power levels event not found - } - // Candidates 2-3: Most popular servers in the room - /** @type {Map${stackLines.join("\n")}${util.inspect(gatewayMessage.d, false, 4, false)}Error: Custom error\n at ./m2d/converters/utils.test.js:3:11)
{ display: 'Custom message data' }Line 2
Line 3Line 4
" - }) -}) - -test("getViaServers: returns the server name if the room only has sim users", async t => { - const result = await getViaServers("!baby", { - getStateEvent: async () => ({}), - getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe"]) - }) - t.deepEqual(result, ["cadence.moe"]) -}) - -test("getViaServers: also returns the most popular servers in order", async t => { - const result = await getViaServers("!baby", { - getStateEvent: async () => ({}), - getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid"]) - }) - t.deepEqual(result, ["cadence.moe", "thecollective.invalid", "selfhosted.invalid"]) -}) - -test("getViaServers: does not return IP address servers", async t => { - const result = await getViaServers("!baby", { - getStateEvent: async () => ({}), - getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:45.77.232.172:8443", "@cadence:[::1]:8443", "@cadence:123example.456example.invalid"]) - }) - t.deepEqual(result, ["cadence.moe", "123example.456example.invalid"]) -}) - -test("getViaServers: also returns the highest power level user (100)", async t => { - const result = await getViaServers("!baby", { - getStateEvent: async () => ({ - users: { - "@moderator:tractor.invalid": 50, - "@singleuser:selfhosted.invalid": 100, - "@_ooye_bot:cadence.moe": 100 - } - }), - getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@moderator:tractor.invalid"]) - }) - t.deepEqual(result, ["cadence.moe", "selfhosted.invalid", "thecollective.invalid", "tractor.invalid"]) -}) - -test("getViaServers: also returns the highest power level user (50)", async t => { - const result = await getViaServers("!baby", { - getStateEvent: async () => ({ - users: { - "@moderator:tractor.invalid": 50, - "@_ooye_bot:cadence.moe": 100 - } - }), - getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"]) - }) - t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"]) -}) - -test("getViaServers: returns at most 4 results", async t => { - const result = await getViaServers("!baby", { - getStateEvent: async () => ({ - users: { - "@moderator:tractor.invalid": 50, - "@singleuser:selfhosted.invalid": 100, - "@_ooye_bot:cadence.moe": 100 - } - }), - getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@cadence:123example.456example.invalid"]) - }) - t.deepEqual(result.length, 4) -}) - -test("getViaServers: returns results even when power levels can't be fetched", async t => { - const result = await getViaServers("!baby", { - getStateEvent: async () => { - throw new Error("event not found or something") - }, - getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@cadence:123example.456example.invalid"]) - }) - t.deepEqual(result.length, 4) -}) - -test("getViaServers: only considers power levels of currently joined members", async t => { - const result = await getViaServers("!baby", { - getStateEvent: async () => ({ - users: { - "@moderator:tractor.invalid": 50, - "@former_moderator:missing.invalid": 100, - "@_ooye_bot:cadence.moe": 100 - } - }), - getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"]) - }) - t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"]) -}) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 985036e..70e293b 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -18,14 +18,20 @@ const addReaction = sync.require("./actions/add-reaction") const redact = sync.require("./actions/redact") /** @type {import("./actions/update-pins")}) */ const updatePins = sync.require("./actions/update-pins") +/** @type {import("./actions/vote")}) */ +const vote = sync.require("./actions/vote") /** @type {import("../matrix/matrix-command-handler")} */ const matrixCommandHandler = sync.require("../matrix/matrix-command-handler") -/** @type {import("./converters/utils")} */ -const utils = sync.require("./converters/utils") +/** @type {import("../matrix/utils")} */ +const utils = sync.require("../matrix/utils") /** @type {import("../matrix/api")}) */ const api = sync.require("../matrix/api") /** @type {import("../d2m/actions/create-room")} */ const createRoom = sync.require("../d2m/actions/create-room") +/** @type {import("../matrix/room-upgrade")} */ +const roomUpgrade = require("../matrix/room-upgrade") +/** @type {import("../d2m/actions/retrigger")} */ +const retrigger = sync.require("../d2m/actions/retrigger") const {reg} = require("../matrix/read-registration") let lastReportedEvent = 0 @@ -82,6 +88,12 @@ function stringifyErrorStack(err, depth = 0) { return collapsed; } +function printError(type, source, e, payload) { + console.error(`Error while processing a ${type} ${source} event:`) + console.error(e) + console.dir(payload, {depth: null}) +} + /** * @param {string} roomID * @param {"Discord" | "Matrix"} source @@ -90,9 +102,9 @@ function stringifyErrorStack(err, depth = 0) { * @param {any} payload */ async function sendError(roomID, source, type, e, payload) { - console.error(`Error while processing a ${type} ${source} event:`) - console.error(e) - console.dir(payload, {depth: null}) + if (source === "Matrix") { + printError(type, source, e, payload) + } if (Date.now() - lastReportedEvent < 5000) return null lastReportedEvent = Date.now() @@ -161,7 +173,7 @@ const errorRetrySema = new Semaphore() */ async function onRetryReactionAdd(reactionEvent) { const roomID = reactionEvent.room_id - errorRetrySema.request(async () => { + await 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 @@ -169,11 +181,10 @@ async function onRetryReactionAdd(reactionEvent) { 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) { + if (reactionEvent.sender !== error.payload.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 + const {powers: {[reactionEvent.sender]: senderPower}, powerLevels} = await utils.getEffectivePower(roomID, [reactionEvent.sender], api) + if (senderPower < (powerLevels.state_default ?? 50)) return } // Retry @@ -200,6 +211,7 @@ async event => { // @ts-ignore await matrixCommandHandler.execute(event) } + retrigger.messageFinishedBridging(event.event_id) await api.ackEvent(event) })) @@ -209,6 +221,55 @@ sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", */ async event => { if (utils.eventSenderIsFromDiscord(event.sender)) return + const messageResponses = await sendEvent.sendEvent(event) + retrigger.messageFinishedBridging(event.event_id) + await api.ackEvent(event) +})) + +sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.start", guard("org.matrix.msc3381.poll.start", +/** + * @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start} event it is a org.matrix.msc3381.poll.start because that's what this listener is filtering for + */ +async event => { + if (utils.eventSenderIsFromDiscord(event.sender)) return + const messageResponses = await sendEvent.sendEvent(event) + await api.ackEvent(event) +})) + +sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.response", guard("org.matrix.msc3381.poll.response", +/** + * @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Response} event it is a org.matrix.msc3381.poll.response because that's what this listener is filtering for + */ +async event => { + if (utils.eventSenderIsFromDiscord(event.sender)) return + await vote.updateVote(event) // Matrix votes can't be bridged, so all we do is store it in the database. + await api.ackEvent(event) +})) + +sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.end", guard("org.matrix.msc3381.poll.end", +/** + * @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} event it is a org.matrix.msc3381.poll.end because that's what this listener is filtering for + */ +async event => { + if (utils.eventSenderIsFromDiscord(event.sender)) return + const pollEventID = event.content["m.relates_to"]?.event_id + if (!pollEventID) return // Validity check + const messageID = select("event_message", "message_id", {event_id: pollEventID, event_type: "org.matrix.msc3381.poll.start", source: 0}).pluck().get() + if (!messageID) return // Nothing can be done if the parent message was never bridged. Also, Discord-native polls cannot be ended by others, so this only works for polls started on Matrix. + try { + var pollEvent = await api.getEvent(event.room_id, pollEventID) // Poll start event must exist for this to be valid + } catch (e) { + return + } + + // According to the rules, the poll end is only allowed if it was sent by the poll starter, or by someone with redact powers. + if (pollEvent.sender !== event.sender) { + const {powerLevels, powers: {[event.sender]: enderPower}} = await utils.getEffectivePower(event.room_id, [event.sender], api) + if (enderPower < (powerLevels.redact ?? 50)) { + return // Not allowed + } + } + const messageResponses = await sendEvent.sendEvent(event) await api.ackEvent(event) })) @@ -302,15 +363,7 @@ async event => { await api.ackEvent(event) })) -function getFromInviteRoomState(inviteRoomState, nskey, key) { - if (!Array.isArray(inviteRoomState)) return null - for (const event of inviteRoomState) { - if (event.type === nskey && event.state_key === "") { - return event.content[key] - } - } - return null -} + sync.addTemporaryListener(as, "type:m.space.child", guard("m.space.child", /** @@ -318,7 +371,18 @@ sync.addTemporaryListener(as, "type:m.space.child", guard("m.space.child", */ async event => { if (Array.isArray(event.content.via) && event.content.via.length) { // space child is being added - await api.joinRoom(event.state_key).catch(() => {}) // try to join if able, it's okay if it doesn't want, bot will still respond to invites + try { + // try to join if able, it's okay if it doesn't want, bot will still respond to invites + await api.joinRoom(event.state_key) + // if autojoined a child space, store it in invite (otherwise the child space will be impossible to use with self-service in the future) + const hierarchy = await api.getHierarchy(event.state_key, {limit: 1}) + const roomProperties = hierarchy.rooms?.[0] + if (roomProperties?.room_id === event.state_key && roomProperties.room_type === "m.space" && roomProperties.name) { + db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)") + .run(event.sender, event.state_key, roomProperties.room_type, roomProperties.name, roomProperties.topic, roomProperties.avatar_url) + await updateMemberCachePowerLevels(event.state_key) // store privileged users in member_cache so they are also allowed to perform self-service + } + } catch (e) {} } })) @@ -328,58 +392,59 @@ 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 === 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! (${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 + if (event.state_key === utils.bot) { + const upgraded = await roomUpgrade.onBotMembership(event, api, createRoom) + if (upgraded) return } - if (utils.eventSenderIsFromDiscord(event.state_key)) return + if (event.content.membership === "invite" && event.state_key === utils.bot) { + // Supposed to be here already? + const guildID = select("guild_space", "guild_id", {space_id: event.room_id}).pluck().get() + if (guildID) { + await api.joinRoom(event.room_id) + return + } + + // We were invited to a room. We should join, and register the invite details for future reference in web. + try { + var inviteRoomState = await api.getInviteState(event.room_id, event) + } catch (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()})`) + } + 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) + 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) + if (inviteRoomState.avatar) utils.getPublicUrlForMxc(inviteRoomState.avatar) // make sure it's available in the media_proxy allowed URLs + await updateMemberCachePowerLevels(event.room_id) // store privileged users in member_cache so they are also allowed to perform self-service + } if (event.content.membership === "leave" || event.content.membership === "ban") { // Member is gone 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 - if (event.state_key === bot) { + // Unregister room's use as a direct chat and/or an invite target if the bot itself left + if (event.state_key === utils.bot) { db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id) + db.prepare("DELETE FROM invite WHERE room_id = ?").run(event.room_id) } } + if (utils.eventSenderIsFromDiscord(event.state_key)) return + const exists = select("channel_room", "room_id", {room_id: event.room_id}) ?? select("guild_space", "space_id", {space_id: event.room_id}) if (!exists) return // don't cache members in unbridged rooms // Member is here - let powerLevel = 0 - try { - /** @type {Ty.Event.M_Power_Levels} */ - const powerLevelsEvent = await api.getStateEvent(event.room_id, "m.room.power_levels", "") - powerLevel = powerLevelsEvent.users?.[event.state_key] ?? powerLevelsEvent.users_default ?? 0 - } catch (e) {} + let {powers: {[event.state_key]: memberPower}, tombstone} = await utils.getEffectivePower(event.room_id, [event.state_key], api) + if (memberPower === Infinity) memberPower = tombstone // database storage compatibility const displayname = event.content.displayname || null const avatar_url = event.content.avatar_url - db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?, power_level = ?").run( + db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?, power_level = ?, missing_profile = NULL").run( event.room_id, event.state_key, - displayname, avatar_url, powerLevel, - displayname, avatar_url, powerLevel + displayname, avatar_url, memberPower, + displayname, avatar_url, memberPower ) })) @@ -389,12 +454,35 @@ sync.addTemporaryListener(as, "type:m.room.power_levels", guard("m.room.power_le */ async event => { if (event.state_key !== "") return - const existingPower = select("member_cache", "mxid", {room_id: event.room_id}).pluck().all() - const newPower = event.content.users || {} - for (const mxid of existingPower) { - db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(newPower[mxid] || 0, event.room_id, mxid) + await updateMemberCachePowerLevels(event.room_id) +})) + +/** + * @param {string} roomID + */ +async function updateMemberCachePowerLevels(roomID) { + const existingPower = select("member_cache", "mxid", {room_id: roomID}).pluck().all() + const {powerLevels, allCreators, tombstone} = await utils.getEffectivePower(roomID, [], api) + const newPower = powerLevels.users || {} + const newPowerUsers = Object.keys(newPower) + const relevantUsers = existingPower.concat(newPowerUsers).concat(allCreators) + for (const mxid of [...new Set(relevantUsers)]) { + const level = allCreators.includes(mxid) ? tombstone : newPower[mxid] ?? powerLevels.users_default ?? 0 + db.prepare("INSERT INTO member_cache (room_id, mxid, power_level, missing_profile) VALUES (?, ?, ?, 1) ON CONFLICT DO UPDATE SET power_level = ?") + .run(roomID, mxid, level, level) } +} + +sync.addTemporaryListener(as, "type:m.room.tombstone", guard("m.room.tombstone", +/** + * @param {Ty.Event.StateOuter${formattedBody}
` + this.formattedBody += formattedBody + } + return this + } + + get() { + return { + msgtype: "m.text", + body: this.body, + format: "org.matrix.custom.html", + formatted_body: this.formattedBody + } + } +} + +/** + * Context: Room IDs are not routable on their own. Room permalinks need a list of servers to try. The client is responsible for coming up with a list of servers. + * ASSUMPTION 1: The bridge bot is a member of the target room and can therefore access its power levels and member list for calculation. + * ASSUMPTION 2: Because the bridge bot is a member of the target room, the target room is bridged. + * https://spec.matrix.org/v1.9/appendices/#routing + * https://gitdab.com/cadence/out-of-your-element/issues/11 + * @param {string} roomID + * @param {{[K in "getStateEvent" | "getStateEventOuter" | "getJoinedMembers"]: import("./api")[K]} | {getEffectivePower: (roomID: string, mxids: string[], api: any) => Promise<{powers: Record${stackLines.join("\n")}${util.inspect(gatewayMessage.d, false, 4, false)}Error: Custom error\n at ./example.test.js:3:11)
{ display: 'Custom message data' }Line 2
Line 3Line 4
" + }) +}) + +/** + * @param {string[]} [creators] + * @param {{[x: string]: number}} [users] + * @param {string} [roomVersion] + */ +function mockGetEffectivePower(creators = ["@_ooye_bot:cadence.moe"], users = {}, roomVersion = "12") { + return async function getEffectivePower(roomID, mxids) { + return { + allCreators: creators, + powerLevels: {users}, + powers: mxids.reduce((a, mxid) => { + if (creators.includes(mxid) && roomHasAtLeastVersion(roomVersion, 12)) a[mxid] = Infinity + else if (mxid in users) a[mxid] = users[mxid] + else a[mxid] = 0 + return a + }, {}), + roomCreate: { + type: "m.room.create", + state_key: "", + sender: creators[0], + content: { + additional_creators: creators.slice(1), + room_version: roomVersion + }, + room_id: roomID, + origin_server_ts: 0, + event_id: "$create" + }, + tombstone: roomVersion === "12" ? 150 : 100, + } + } +} + +test("getViaServers: returns the server name if the room only has sim users", async t => { + const result = await getViaServers("!baby", { + getEffectivePower: mockGetEffectivePower(), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe"]) + }) + t.deepEqual(result, ["cadence.moe"]) +}) + +test("getViaServers: also returns the most popular servers in order", async t => { + const result = await getViaServers("!baby", { + getEffectivePower: mockGetEffectivePower(), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid"]) + }) + t.deepEqual(result, ["cadence.moe", "thecollective.invalid", "selfhosted.invalid"]) +}) + +test("getViaServers: does not return IP address servers", async t => { + const result = await getViaServers("!baby", { + getEffectivePower: mockGetEffectivePower(), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:45.77.232.172:8443", "@cadence:[::1]:8443", "@cadence:123example.456example.invalid"]) + }) + t.deepEqual(result, ["cadence.moe", "123example.456example.invalid"]) +}) + +test("getViaServers: also returns the highest power level user (v12 creator)", async t => { + const result = await getViaServers("!baby", { + getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe", "@singleuser:selfhosted.invalid"], { + "@moderator:tractor.invalid": 50 + }), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@moderator:tractor.invalid"]) + }) + t.deepEqual(result, ["cadence.moe", "selfhosted.invalid", "thecollective.invalid", "tractor.invalid"]) +}) + +test("getViaServers: also returns the highest power level user (100)", async t => { + const result = await getViaServers("!baby", { + getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe"], { + "@moderator:tractor.invalid": 50, + "@singleuser:selfhosted.invalid": 100 + }), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@moderator:tractor.invalid"]) + }) + t.deepEqual(result, ["cadence.moe", "selfhosted.invalid", "thecollective.invalid", "tractor.invalid"]) +}) + +test("getViaServers: also returns the highest power level user (50)", async t => { + const result = await getViaServers("!baby", { + getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe"], { + "@moderator:tractor.invalid": 50 + }), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"]) + }) + t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"]) +}) + +test("getViaServers: returns at most 4 results", async t => { + const result = await getViaServers("!baby", { + getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe"], { + "@moderator:tractor.invalid": 50, + "@singleuser:selfhosted.invalid": 100 + }), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@cadence:123example.456example.invalid"]) + }) + t.deepEqual(result.length, 4) +}) + +test("getViaServers: only considers power levels of currently joined members", async t => { + const result = await getViaServers("!baby", { + getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe", "@former_moderator:missing.invalid"], { + "@moderator:tractor.invalid": 50 + }), + getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"]) + }) + t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"]) +}) + +test("roomHasAtLeastVersion: v9 < v11", t => { + t.equal(roomHasAtLeastVersion("9", 11), false) +}) + +test("roomHasAtLeastVersion: v12 >= v11", t => { + t.equal(roomHasAtLeastVersion("12", 11), true) +}) + +test("roomHasAtLeastVersion: v12 >= v12", t => { + t.equal(roomHasAtLeastVersion("12", 12), true) +}) + +test("roomHasAtLeastVersion: custom versions never match", t => { + t.equal(roomHasAtLeastVersion("moe.cadence.silly", 11), false) +}) + +test("removeCreatorsFromPowerLevels: removes the creator from a v12 room", t => { + t.deepEqual(removeCreatorsFromPowerLevels({ + type: "m.room.create", + state_key: "", + sender: "@_ooye_bot:cadence.moe", + room_id: "!example", + event_id: "$create", + origin_server_ts: 0, + content: { + room_version: "12" + } + }, { + users: { + "@_ooye_bot:cadence.moe": 100 + } + }), { + users: { + } + }) +}) + +test("removeCreatorsFromPowerLevels: removes all creators from a v12 room", t => { + t.deepEqual(removeCreatorsFromPowerLevels({ + type: "m.room.create", + state_key: "", + sender: "@_ooye_bot:cadence.moe", + room_id: "!example", + event_id: "$create", + origin_server_ts: 0, + content: { + additional_creators: ["@cadence:cadence.moe"], + room_version: "12" + } + }, { + users: { + "@_ooye_bot:cadence.moe": 100, + "@cadence:cadence.moe": 100 + } + }), { + users: { + } + }) +}) + +test("removeCreatorsFromPowerLevels: doesn't touch a v11 room", t => { + t.deepEqual(removeCreatorsFromPowerLevels({ + type: "m.room.create", + state_key: "", + sender: "@_ooye_bot:cadence.moe", + room_id: "!example", + event_id: "$create", + origin_server_ts: 0, + content: { + additional_creators: ["@cadence:cadence.moe"], + room_version: "11" + } + }, { + users: { + "@_ooye_bot:cadence.moe": 100, + "@cadence:cadence.moe": 100 + } + }), { + users: { + "@_ooye_bot:cadence.moe": 100, + "@cadence:cadence.moe": 100 + } + }) +}) + +test("set user power: no-op", async t => { + let called = 0 + await setUserPower("!room", "@cadence:cadence.moe", 0, { + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!room") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {} + }, + async getStateEventOuter(roomID, type, key) { + called++ + t.equal(roomID, "!room") + t.equal(type, "m.room.create") + t.equal(key, "") + return { + type: "m.room.create", + state_key: "", + sender: "@_ooye_bot:cadence.moe", + room_id: "!room", + origin_server_ts: 0, + event_id: "$create", + content: { + room_version: "11" + } + } + }, + /* c8 ignore next 4 */ + async sendState() { + called++ + throw new Error("should not try to send state") + } + }) + t.equal(called, 2) +}) + +test("set user power: bridge bot must promote unprivileged users", async t => { + let called = 0 + await setUserPower("!room", "@cadence:cadence.moe", 100, { + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!room") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: {"@_ooye_bot:cadence.moe": 100} + } + }, + async getStateEventOuter(roomID, type, key) { + called++ + t.equal(roomID, "!room") + t.equal(type, "m.room.create") + t.equal(key, "") + return { + type: "m.room.create", + state_key: "", + sender: "@_ooye_bot:cadence.moe", + room_id: "!room", + origin_server_ts: 0, + event_id: "$create", + content: { + room_version: "11" + } + } + }, + async sendState(roomID, type, key, content, mxid) { + called++ + t.equal(roomID, "!room") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + t.deepEqual(content, { + users: { + "@_ooye_bot:cadence.moe": 100, + "@cadence:cadence.moe": 100 + } + }) + t.equal(mxid, undefined) + return "$sent" + } + }) + t.equal(called, 3) +}) + +test("set user power: privileged users must demote themselves", async t => { + let called = 0 + await setUserPower("!room", "@cadence:cadence.moe", 0, { + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!room") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@cadence:cadence.moe": 100, + "@_ooye_bot:cadence.moe": 100 + } + } + }, + async getStateEventOuter(roomID, type, key) { + called++ + t.equal(roomID, "!room") + t.equal(type, "m.room.create") + t.equal(key, "") + return { + type: "m.room.create", + state_key: "", + sender: "@_ooye_bot:cadence.moe", + room_id: "!room", + origin_server_ts: 0, + event_id: "$create", + content: { + room_version: "11" + } + } + }, + async sendState(roomID, type, key, content, mxid) { + called++ + t.equal(roomID, "!room") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + t.deepEqual(content, { + users: {"@_ooye_bot:cadence.moe": 100} + }) + t.equal(mxid, "@cadence:cadence.moe") + return "$sent" + } + }) + t.equal(called, 3) +}) + +module.exports.mockGetEffectivePower = mockGetEffectivePower diff --git a/src/types.d.ts b/src/types.d.ts index f9488b9..a85907d 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,3 +1,5 @@ +import * as DiscordTypes from "discord-api-types/v10" + export type AppServiceRegistrationConfig = { id: string as_token: string @@ -143,21 +145,6 @@ export namespace Event { } } - export type BaseStateEvent = { - type: string - room_id: string - sender: string - content: any - state_key: string - origin_server_ts: number - unsigned?: any - event_id: string - user_id: string - age: number - replaces_state: string - prev_content?: any - } - export type StrippedChildStateEvent = { type: string state_key: string @@ -174,7 +161,7 @@ export namespace Event { } export type M_Room_Create = { - additional_creators: string[] + additional_creators?: string[] "m.federate"?: boolean room_version: string type?: string @@ -221,6 +208,7 @@ export namespace Event { filename?: string url: string info?: any + "page.codeberg.everypizza.msc4193.spoiler"?: boolean "m.relates_to"?: { "m.in_reply_to": { event_id: string @@ -238,6 +226,7 @@ export namespace Event { format?: "org.matrix.custom.html" formatted_body?: string filename?: string + "page.codeberg.everypizza.msc4193.spoiler"?: boolean file: { url: string iv: string @@ -282,6 +271,49 @@ export namespace Event { export type Outer_M_Sticker = Outer