, 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) @@ -531,24 +656,45 @@ async function messageToEvent(message, guild, options = {}, di) { message.content = "changed the channel name to **" + message.content + "**" } + // Handle message type 63, new emoji announcement + // @ts-expect-error - should be changed to a DiscordTypes reference once it has been documented + if (message.type === 63) { + const match = message.content.match(/^<(a?):([^:>]{1,64}):([0-9]+)>$/) + assert(match, `message type 63, which announces a new emoji, did not include an emoji. the actual content was: "${message.content}"`) + const name = match[2] + msgtype = "m.emote" + 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 { @@ -565,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}` } @@ -582,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) } @@ -650,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 @@ -665,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) { @@ -768,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": { @@ -778,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 00000000..7d875a6b --- /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 ed165c64..259aa668 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 ee4ec037..1a73aea7 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 ce83d544..1323280c 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]
node
- const replacementCode = doc.createElement("code")
- replacementCode.textContent = `[${filename}]`
- // Build its containing node
- const replacement = doc.createElement("span")
- replacement.appendChild(doc.createTextNode(" "))
- replacement.appendChild(replacementCode)
- replacement.appendChild(doc.createTextNode(" "))
- // Replace the code block with the
- preNode.replaceWith(replacement)
- // Upload the code as an attachment
- const content = getCodeContent(preNode.firstChild)
- attachments.push({id: String(attachments.length), filename})
- pendingFiles.push({name: filename, buffer: Buffer.from(content, "utf8")})
- }
+ })
+
+ // Handling mentions of rooms and room-messages
+ input = await handleRoomOrMessageLinks(input, di)
+
+ // Stripping colons after mentions
+ input = input.replace(/( data-user-id.*?<\/a>):?/g, "$1")
+ input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1")
+
+ // Element adds a bunch of
before but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those.
+ input = input.replace(/(?:\n|
\s*)*<\/blockquote>/g, "")
+
+ // The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason.
+ // But I should not count it if it's between block elements.
+ input = input.replace(/(<\/?([^ >]+)[^>]*>)?\n(<\/?([^ >]+)[^>]*>)?/g, (whole, beforeContext, beforeTag, afterContext, afterTag) => {
+ if (typeof beforeTag !== "string" && typeof afterTag !== "string") {
+ return "
"
+ }
+ beforeContext = beforeContext || ""
+ beforeTag = beforeTag || ""
+ afterContext = afterContext || ""
+ afterTag = afterTag || ""
+ if (!mxUtils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !mxUtils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) {
+ return beforeContext + "
" + afterContext
+ } else {
+ return whole
+ }
+ })
+
+ // Note: Element's renderers on Web and Android currently collapse whitespace, like the browser does. Turndown also collapses whitespace which is good for me.
+ // If later I'm using a client that doesn't collapse whitespace and I want turndown to follow suit, uncomment the following line of code, and it Just Works:
+ // input = input.replace(/ /g, " ")
+ // There is also a corresponding test to uncomment, named "event2message: whitespace is retained"
+
+ // Handling written @mentions: we need to look for candidate Discord members to join to the room
+ // This shouldn't apply to code blocks, links, or inside attributes. So editing the HTML tree instead of regular expressions is a sensible choice here.
+ // We're using the domino parser because Turndown uses the same and can reuse this tree.
+ const doc = domino.createDocument(
+ // DOM parsers arrange elements in the and . Wrapping in a custom element ensures elements are reliably arranged in a single element.
+ '' + input + ' '
+ );
+ const root = doc.getElementById("turndown-root");
+ async function forEachNode(event, node) {
+ for (; node; node = node.nextSibling) {
+ // Check written mentions
+ if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) {
+ const result = await checkWrittenMentions(node.nodeValue, event.sender, event.room_id, guild, di)
+ if (result) {
+ node.nodeValue = result.content
+ ensureJoined.push(...result.ensureJoined)
+ allowedMentionsParse.push(...result.allowedMentionsParse)
+ }
+ }
+ // Check for incompatible backticks in code blocks
+ let preNode
+ if (node.nodeType === 3 && node.nodeValue.includes("```") && (preNode = nodeIsChildOf(node, ["PRE"]))) {
+ if (preNode.firstChild?.nodeName === "CODE") {
+ const ext = preNode.firstChild.className.match(/language-(\S+)/)?.[1] || "txt"
+ const filename = `inline_code.${ext}`
+ // Build the replacement node
+ const replacementCode = doc.createElement("code")
+ replacementCode.textContent = `[${filename}]`
+ // Build its containing node
+ const replacement = doc.createElement("span")
+ replacement.appendChild(doc.createTextNode(" "))
+ replacement.appendChild(replacementCode)
+ replacement.appendChild(doc.createTextNode(" "))
+ // Replace the code block with the
+ preNode.replaceWith(replacement)
+ // Upload the code as an attachment
+ const content = getCodeContent(preNode.firstChild)
+ attachments.push({id: String(attachments.length), filename})
+ pendingFiles.push({name: filename, buffer: Buffer.from(content, "utf8")})
+ }
+ }
+ // Suppress link embeds
+ if (node.nodeType === 1 && node.tagName === "A") {
+ // Suppress if sender tried to add angle brackets
+ const inBody = event.content.body.indexOf(node.getAttribute("href"))
+ let shouldSuppress = inBody !== -1 && event.content.body[inBody-1] === "<"
+ if (!shouldSuppress && guild?.roles) {
+ // Suppress if regular users don't have permission
+ const permissions = dUtils.getPermissions(guild.id, [], guild.roles)
+ const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks)
+ shouldSuppress = !canEmbedLinks
+ }
+ if (shouldSuppress) {
+ node.setAttribute("data-suppress", "")
+ }
+ }
+ await forEachNode(event, node.firstChild)
}
- await forEachNode(node.firstChild)
}
+ await forEachNode(event, root)
+
+ // SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
+ // First we need to determine which emojis are at the end.
+ endOfMessageEmojis = []
+ let match
+ let last = input.length
+ while ((match = input.slice(0, last).match(/
]*>\s*$/))) {
+ if (!match[0].includes("data-mx-emoticon")) break
+ const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/)
+ if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1])
+ assert(typeof match.index === "number", "Your JavaScript implementation does not comply with TC39: https://tc39.es/ecma262/multipage/text-processing.html#sec-regexpbuiltinexec")
+ last = match.index
+ }
+
+ // @ts-ignore bad type from turndown
+ content = turndownService.turndown(root)
+
+ // Put < > around any surviving matrix.to links to hide the URL previews
+ content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/g, "<$&>")
+
+ // It's designed for commonmark, we need to replace the space-space-newline with just newline
+ content = content.replace(/ \n/g, "\n")
+
+ // If there's a blockquote at the start of the message body and this message is a reply, they should be visually separated
+ if (replyLine && content.startsWith("> ")) content = "\n" + content
+
+ // SPRITE SHEET EMOJIS FEATURE:
+ content = await linkEndOfMessageSpriteSheet(content)
+ } else {
+ // Looks like we're using the plaintext body!
+ content = event.content.body
+
+ if (event.content.msgtype === "m.emote") {
+ content = `* ${displayName} ${content}`
+ }
+
+ content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible
+
+ let offset = 0
+ for (const match of [...content.matchAll(/\bhttps?:\/\/[^ )>\n]+/g)]) {
+ assert(typeof match.index === "number")
+
+ // Respect sender's angle brackets
+ const alreadySuppressed = content[match.index-1+offset] === "<" && content[match.index+match.length+offset] === ">"
+ if (alreadySuppressed) continue
+
+ // Suppress matrix.to links always
+ let shouldSuppress = !!match[0].match(/^https?:\/\/matrix\.to\//)
+
+ // Suppress if regular users don't have permission
+ if (!shouldSuppress && guild?.roles) {
+ const permissions = dUtils.getPermissions(guild.id, [], guild.roles, undefined, channel.permission_overwrites)
+ const canEmbedLinks = dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.EmbedLinks)
+ shouldSuppress = !canEmbedLinks
+ }
+
+ if (shouldSuppress) {
+ content = content.slice(0, match.index + offset) + "<" + match[0] + ">" + content.slice(match.index + match[0].length + offset)
+ offset += 2
+ }
+ }
+
+ const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di)
+ if (result) {
+ content = result.content
+ ensureJoined.push(...result.ensureJoined)
+ allowedMentionsParse.push(...result.allowedMentionsParse)
+ }
+
+ // Markdown needs to be escaped, though take care not to escape the middle of links
+ // @ts-ignore bad type from turndown
+ content = turndownService.escape(content)
}
- await forEachNode(root)
-
- // SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
- // First we need to determine which emojis are at the end.
- endOfMessageEmojis = []
- let match
- let last = input.length
- while ((match = input.slice(0, last).match(/
]*>\s*$/))) {
- if (!match[0].includes("data-mx-emoticon")) break
- const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/)
- if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1])
- assert(typeof match.index === "number", "Your JavaScript implementation does not comply with TC39: https://tc39.es/ecma262/multipage/text-processing.html#sec-regexpbuiltinexec")
- last = match.index
- }
-
- // @ts-ignore bad type from turndown
- content = turndownService.turndown(root)
-
- // Put < > around any surviving matrix.to links to hide the URL previews
- content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/g, "<$&>")
-
- // It's designed for commonmark, we need to replace the space-space-newline with just newline
- content = content.replace(/ \n/g, "\n")
-
- // If there's a blockquote at the start of the message body and this message is a reply, they should be visually separated
- if (replyLine && content.startsWith("> ")) content = "\n" + content
-
- // SPRITE SHEET EMOJIS FEATURE:
- content = await uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, di?.mxcDownloader)
- } else {
- // Looks like we're using the plaintext body!
- content = event.content.body
-
- if (event.content.msgtype === "m.emote") {
- content = `* ${displayName} ${content}`
- }
-
- content = await handleRoomOrMessageLinks(content, di) // Replace matrix.to links with discord.com equivalents where possible
- content = content.replace(/\bhttps?:\/\/matrix\.to\/[^<>\n )]*/, "<$&>") // Put < > around any surviving matrix.to links to hide the URL previews
-
- const result = await checkWrittenMentions(content, event.sender, event.room_id, guild, di)
- if (result) {
- content = result.content
- ensureJoined.push(...result.ensureJoined)
- allowedMentionsParse.push(...result.allowedMentionsParse)
- }
-
- // Markdown needs to be escaped, though take care not to escape the middle of links
- // @ts-ignore bad type from turndown
- content = turndownService.escape(content)
}
}
@@ -888,6 +1013,16 @@ async function eventToMessage(event, guild, di) {
messages[0].pendingFiles = pendingFiles
}
+ if (pollMessages.length) {
+ for (const pollMessage of pollMessages) {
+ messages.push({
+ username: displayNameShortened,
+ avatar_url: avatarURL,
+ ...pollMessage,
+ })
+ }
+ }
+
const messagesToEdit = []
const messagesToSend = []
for (let i = 0; i < messages.length; i++) {
diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js
index 3d1c918c..629f2b88 100644
--- a/src/m2d/converters/event-to-message.test.js
+++ b/src/m2d/converters/event-to-message.test.js
@@ -1,21 +1,11 @@
const assert = require("assert").strict
-const fs = require("fs")
const {test} = require("supertape")
+const DiscordTypes = require("discord-api-types/v10")
const {eventToMessage} = require("./event-to-message")
-const {convertImageStream} = require("./emoji-sheet")
const data = require("../../../test/data")
const {MatrixServerError} = require("../../matrix/mreq")
const {select, discord} = require("../../passthrough")
-/* c8 ignore next 7 */
-function slow() {
- if (process.argv.includes("--slow")) {
- return test
- } else {
- return test.skip
- }
-}
-
/**
* @param {string} roomID
* @param {string} eventID
@@ -48,25 +38,6 @@ function sameFirstContentAndWhitespace(t, a, b) {
t.equal(a2, b2)
}
-/**
- * MOCK: Gets the emoji from the filesystem and converts to uncompressed PNG data.
- * @param {string} mxc a single mxc:// URL
- * @returns {Promise} uncompressed PNG data, or undefined if the downloaded emoji is not valid
-*/
-async function mockGetAndConvertEmoji(mxc) {
- const id = mxc.match(/\/([^./]*)$/)?.[1]
- let s
- if (fs.existsSync(`test/res/${id}.png`)) {
- s = fs.createReadStream(`test/res/${id}.png`)
- } else {
- s = fs.createReadStream(`test/res/${id}.gif`)
- }
- return convertImageStream(s, () => {
- s.pause()
- s.emit("end")
- })
-}
-
test("event2message: body is used when there is no formatted_body", async t => {
t.deepEqual(
await eventToMessage({
@@ -114,7 +85,7 @@ test("event2message: any markdown in body is escaped, except strikethrough", asy
unsigned: {
age: 405299
}
- }, {}, {
+ }, {}, {}, {
snow: {
guild: {
searchGuildMembers: () => []
@@ -302,6 +273,287 @@ test("event2message: markdown in link text does not attempt to be escaped becaus
)
})
+test("event2message: markdown in link url does not attempt to be escaped (plaintext body, not suppressed)", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ msgtype: "m.text",
+ body: "the wikimedia commons freaks are gonna love this one https://commons.wikimedia.org/wiki/File:Car_covered_in_traffic_cones.jpg"
+ },
+ event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ type: "m.room.message"
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "the wikimedia commons freaks are gonna love this one https://commons.wikimedia.org/wiki/File:Car_covered_in_traffic_cones.jpg",
+ avatar_url: undefined,
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+})
+
+test("event2message: markdown in link url does not attempt to be escaped (plaintext body, link suppressed)", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ msgtype: "m.text",
+ body: "the wikimedia commons freaks are gonna love this one https://commons.wikimedia.org/wiki/File:Car_covered_in_traffic_cones.jpg"
+ },
+ event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ type: "m.room.message"
+ }, {
+ id: "123",
+ roles: [{
+ id: "123",
+ name: "@everyone",
+ permissions: DiscordTypes.PermissionFlagsBits.SendMessages
+ }]
+ }, {}),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "the wikimedia commons freaks are gonna love this one ",
+ avatar_url: undefined,
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+})
+
+test("event2message: embeds are suppressed if the guild does not have embed links permission (formatted body)", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ body: "posting one of my favourite songs recently (starts at timestamp) https://youtu.be/RhV2X7WQMPA?t=364",
+ format: "org.matrix.custom.html",
+ formatted_body: `posting one of my favourite songs recently (starts at timestamp) https://youtu.be/RhV2X7WQMPA?t=364`,
+ msgtype: "m.text"
+ },
+ event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
+ origin_server_ts: 1688301929913,
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ type: "m.room.message",
+ }, {
+ id: "123",
+ roles: [{
+ id: "123",
+ name: "@everyone",
+ permissions: DiscordTypes.PermissionFlagsBits.SendMessages
+ }]
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "posting one of my favourite songs recently (starts at timestamp) ",
+ avatar_url: undefined,
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+})
+
+test("event2message: embeds are suppressed if the guild does not have embed links permission (plaintext body)", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ body: "posting one of my favourite songs recently (starts at timestamp) https://youtu.be/RhV2X7WQMPA?t=364",
+ msgtype: "m.text"
+ },
+ event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
+ origin_server_ts: 1688301929913,
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ type: "m.room.message",
+ }, {
+ id: "123",
+ roles: [{
+ id: "123",
+ name: "@everyone",
+ permissions: DiscordTypes.PermissionFlagsBits.SendMessages
+ }]
+ }, {}),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "posting one of my favourite songs recently (starts at timestamp) ",
+ avatar_url: undefined,
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+})
+
+test("event2message: embeds are suppressed if the channel does not have embed links permission (plaintext body)", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ body: "posting one of my favourite songs recently (starts at timestamp) https://youtu.be/RhV2X7WQMPA?t=364",
+ msgtype: "m.text"
+ },
+ event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
+ origin_server_ts: 1688301929913,
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ type: "m.room.message",
+ }, {
+ id: "123",
+ roles: [{
+ id: "123",
+ name: "@everyone",
+ permissions: DiscordTypes.PermissionFlagsBits.SendMessages | DiscordTypes.PermissionFlagsBits.EmbedLinks
+ }]
+ }, {
+ permission_overwrites: [{
+ id: "123",
+ type: 0,
+ deny: String(DiscordTypes.PermissionFlagsBits.EmbedLinks),
+ allow: "0"
+ }]
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "posting one of my favourite songs recently (starts at timestamp) ",
+ avatar_url: undefined,
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+})
+
+test("event2message: links retain angle brackets (formatted body)", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ body: "posting one of my favourite songs recently (starts at timestamp) ",
+ format: "org.matrix.custom.html",
+ formatted_body: `posting one of my favourite songs recently (starts at timestamp) https://youtu.be/RhV2X7WQMPA?t=364`,
+ msgtype: "m.text"
+ },
+ event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
+ origin_server_ts: 1688301929913,
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ type: "m.room.message",
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "posting one of my favourite songs recently (starts at timestamp) ",
+ avatar_url: undefined,
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+})
+
+test("event2message: links retain angle brackets (plaintext body)", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ body: "posting one of my favourite songs recently (starts at timestamp) ",
+ msgtype: "m.text"
+ },
+ event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
+ origin_server_ts: 1688301929913,
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ type: "m.room.message",
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "posting one of my favourite songs recently (starts at timestamp) ",
+ avatar_url: undefined,
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+})
+
+test("event2message: links don't have angle brackets added by accident", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ "content": {
+ "body": "Wanted to automate WG→AWG config enrichment and ended up basically coding a batch INI processor.\nhttps://github.com/Erquint/wgcbp",
+ "m.mentions": {},
+ "msgtype": "m.text"
+ },
+ "origin_server_ts": 1767848218369,
+ "sender": "@erquint:agiadn.org",
+ "type": "m.room.message",
+ "unsigned": {
+ "membership": "join"
+ },
+ "event_id": "$DxPjyI88VYsJGKuGmhFivFeKn-i5MEBEnAhabmsBaXQ",
+ "room_id": "!zq94fae5bVKUubZLp7:agiadn.org"
+ }, {}, {}, {
+ api: {
+ async getStateEvent(roomID, type, key) {
+ return {
+ displayname: "Erquint"
+ }
+ }
+ }
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "Erquint",
+ content: "Wanted to automate WG→AWG config enrichment and ended up basically coding a batch INI processor.\nhttps://github.com/Erquint/wgcbp",
+ avatar_url: undefined,
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+})
+
test("event2message: basic html is converted to markdown", async t => {
t.deepEqual(
await eventToMessage({
@@ -404,6 +656,135 @@ test("event2message: spoiler reasons work", async t => {
)
})
+test("event2message: media spoilers work", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ body: "pitstop.png",
+ filename: "pitstop.png",
+ info: {
+ h: 870,
+ mimetype: "image/png",
+ size: 729990,
+ w: 674,
+ "xyz.amorgan.blurhash": "UqOMmRM{_Mx[xZaxR*tQ.8ayxtWBRkRkWUWB"
+ },
+ msgtype: "m.image",
+ "page.codeberg.everypizza.msc4193.spoiler": true,
+ url: "mxc://agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT"
+ },
+ origin_server_ts: 1764885561299,
+ room_id: "!zq94fae5bVKUubZLp7:agiadn.org",
+ sender: "@underscore_x:agiadn.org",
+ type: "m.room.message",
+ event_id: "$6P7u-lpu2u73ZrHUru2UG1rPfsh8PfYLPK21o3SNIN4",
+ user_id: "@underscore_x:agiadn.org"
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "underscore_x",
+ content: "",
+ avatar_url: undefined,
+ attachments: [{id: "0", filename: "SPOILER_pitstop.png"}],
+ pendingFiles: [{
+ mxc: "mxc://agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT",
+ name: "SPOILER_pitstop.png",
+ }]
+ }]
+ }
+ )
+})
+
+test("event2message: media spoilers with reason work", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ body: "pitstop.png",
+ filename: "pitstop.png",
+ info: {
+ h: 870,
+ mimetype: "image/png",
+ size: 729990,
+ w: 674,
+ "xyz.amorgan.blurhash": "UqOMmRM{_Mx[xZaxR*tQ.8ayxtWBRkRkWUWB"
+ },
+ msgtype: "m.image",
+ "page.codeberg.everypizza.msc4193.spoiler": true,
+ "page.codeberg.everypizza.msc4193.spoiler.reason": "golden witch solutions",
+ url: "mxc://agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT"
+ },
+ origin_server_ts: 1764885561299,
+ room_id: "!zq94fae5bVKUubZLp7:agiadn.org",
+ sender: "@underscore_x:agiadn.org",
+ type: "m.room.message",
+ event_id: "$6P7u-lpu2u73ZrHUru2UG1rPfsh8PfYLPK21o3SNIN4",
+ user_id: "@underscore_x:agiadn.org"
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "underscore_x",
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ },
+ content: "(Spoiler: golden witch solutions)",
+ avatar_url: undefined,
+ attachments: [{id: "0", filename: "SPOILER_pitstop.png"}],
+ pendingFiles: [{
+ mxc: "mxc://agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT",
+ name: "SPOILER_pitstop.png",
+ }]
+ }]
+ }
+ )
+})
+
+test("event2message: spoiler files too large for Discord are linked and retain reason", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ body: "pitstop.png",
+ filename: "pitstop.png",
+ info: {
+ h: 870,
+ mimetype: "image/png",
+ size: 40000000,
+ w: 674,
+ "xyz.amorgan.blurhash": "UqOMmRM{_Mx[xZaxR*tQ.8ayxtWBRkRkWUWB"
+ },
+ msgtype: "m.image",
+ "page.codeberg.everypizza.msc4193.spoiler": true,
+ "page.codeberg.everypizza.msc4193.spoiler.reason": "golden witch secrets",
+ url: "mxc://agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT"
+ },
+ origin_server_ts: 1764885561299,
+ room_id: "!zq94fae5bVKUubZLp7:agiadn.org",
+ sender: "@underscore_x:agiadn.org",
+ type: "m.room.message",
+ event_id: "$6P7u-lpu2u73ZrHUru2UG1rPfsh8PfYLPK21o3SNIN4",
+ user_id: "@underscore_x:agiadn.org"
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "underscore_x",
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ },
+ content: "(Spoiler: golden witch secrets)\n🖼️ _Uploaded **SPOILER** file: ||[pitstop.png](https://bridge.example.org/download/matrix/agiadn.org/JY5NvEFojTvYDp5znjGIkkQ7Ez7GwsdT )|| (40 MB)_",
+ avatar_url: undefined
+ }]
+ }
+ )
+})
+
test("event2message: markdown syntax is escaped", async t => {
t.deepEqual(
await eventToMessage({
@@ -1052,7 +1433,7 @@ test("event2message: rich reply to a sim user", async t => {
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", {
type: "m.room.message",
@@ -1102,7 +1483,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c
unsigned: {},
event_id: "$Q5kNrPxGs31LfWOhUul5I03jNjlxKOwRmWVuivaqCHY",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0", {
"type": "m.room.message",
@@ -1177,7 +1558,7 @@ test("event2message: rich reply to an already-edited message will quote the new
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$DSQvWxOBB2DYaei6b83-fb33dQGYt5LJd_s8Nl2a43Q", {
type: "m.room.message",
@@ -1260,7 +1641,7 @@ test("event2message: rich reply to a missing event will quote from formatted_bod
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
async getEvent(roomID, eventID) {
called++
@@ -1310,7 +1691,7 @@ test("event2message: rich reply to a missing event without formatted_body will u
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
async getEvent(roomID, eventID) {
called++
@@ -1361,7 +1742,7 @@ test("event2message: rich reply to a missing event and no reply fallback will no
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
async getEvent(roomID, eventID) {
called++
@@ -1406,7 +1787,7 @@ test("event2message: should avoid using blockquote contents as reply preview in
},
event_id: "$BpGx8_vqHyN6UQDARPDU51ftrlRBhleutRSgpAJJ--g",
room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", {
"type": "m.room.message",
@@ -1457,7 +1838,7 @@ test("event2message: should suppress embeds for links in reply preview", async t
},
event_id: "$0Bs3rbsXaeZmSztGMx1NIsqvOrkXOpIWebN-dqr09i4",
room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU", {
"type": "m.room.message",
@@ -1506,7 +1887,7 @@ test("event2message: should include a reply preview when message ends with a blo
},
event_id: "$n6sg1X9rLeMzCYufJTRvaLzFeLQ-oEXjCWkHtRxcem4",
room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$uXM2I6w-XMtim14-OSZ_8Z2uQ6MDAZLT37eYIiEU6KQ", {
type: 'm.room.message',
@@ -1595,7 +1976,7 @@ test("event2message: should include a reply preview when replying to a descripti
},
event_id: "$qCOlszCawu5hlnF2a2PGyXeGGvtoNJdXyRAEaTF0waA",
room_id: "!CzvdIdUQXgUjDVKxeU:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!CzvdIdUQXgUjDVKxeU:cadence.moe", "$zJFjTvNn1w_YqpR4o4ISKUFisNRgZcu1KSMI_LADPVQ", {
type: "m.room.message",
@@ -1680,7 +2061,7 @@ test("event2message: entities are not escaped in main message or reply preview",
},
event_id: "$2I7odT9okTdpwDcqOjkJb_A3utdO4V8Cp3LK6-Rvwcs",
room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$yIWjZPi6Xk56fBxJwqV4ANs_hYLjnWI2cNKbZ2zwk60", {
type: "m.room.message",
@@ -1732,7 +2113,7 @@ test("event2message: reply preview converts emoji formatting when replying to a
},
event_id: "$bCMLaLiMfoRajaGTgzaxAci-g8hJfkspVJIKwYktnvc",
room_id: "!TqlyQmifxGUggEmdBN:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$zmO-dtPO6FubBkDxJZ5YmutPIsG1RgV5JJku-9LeGWs", {
type: "m.room.message",
@@ -1782,7 +2163,7 @@ test("event2message: reply preview can guess custom emoji based on the name if i
},
event_id: "$bCMLaLiMfoRajaGTgzaxAci-g8hJfkspVJIKwYktnvc",
room_id: "!TqlyQmifxGUggEmdBN:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$zmO-dtPO6FubBkDxJZ5YmutPIsG1RgV5JJku-9LeGWs", {
type: "m.room.message",
@@ -1832,7 +2213,7 @@ test("event2message: reply preview uses emoji title text when replying to an unk
},
event_id: "$bCMLaLiMfoRajaGTgzaxAci-g8hJfkspVJIKwYktnvc",
room_id: "!TqlyQmifxGUggEmdBN:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$zmO-dtPO6FubBkDxJZ5YmutPIsG1RgV5JJku-9LeGWs", {
type: "m.room.message",
@@ -1882,7 +2263,7 @@ test("event2message: reply preview ignores garbage image", async t => {
},
event_id: "$bCMLaLiMfoRajaGTgzaxAci-g8hJfkspVJIKwYktnvc",
room_id: "!TqlyQmifxGUggEmdBN:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$zmO-dtPO6FubBkDxJZ5YmutPIsG1RgV5JJku-9LeGWs", {
type: "m.room.message",
@@ -1932,7 +2313,7 @@ test("event2message: reply to empty message doesn't show an extra line or anythi
},
event_id: "$bCMLaLiMfoRajaGTgzaxAci-g8hJfkspVJIKwYktnvc",
room_id: "!TqlyQmifxGUggEmdBN:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$zmO-dtPO6FubBkDxJZ5YmutPIsG1RgV5JJku-9LeGWs", {
type: "m.room.message",
@@ -1992,7 +2373,7 @@ test("event2message: editing a rich reply to a sim user", async t => {
},
"event_id": "$XEgssz13q-a7NLO7UZO2Oepq7tSiDBD7YRfr7Xu_QiA",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: (roomID, eventID) => {
assert.ok(eventID === "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04" || eventID === "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8")
@@ -2073,7 +2454,7 @@ test("event2message: editing a plaintext body message", async t => {
},
"event_id": "$KxGwvVNzNcmlVbiI2m5kX-jMFNi3Jle71-uu1j7P7vM",
"room_id": "!BnKuBPCvyfOkhcUjEu:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs", {
type: "m.room.message",
@@ -2128,7 +2509,7 @@ test("event2message: editing a plaintext message to be longer", async t => {
},
"event_id": "$KxGwvVNzNcmlVbiI2m5kX-jMFNi3Jle71-uu1j7P7vM",
"room_id": "!BnKuBPCvyfOkhcUjEu:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs", {
type: "m.room.message",
@@ -2190,7 +2571,7 @@ test("event2message: editing a plaintext message to be shorter", async t => {
},
"event_id": "$KxGwvVNzNcmlVbiI2m5kX-jMFNi3Jle71-uu1j7P7vM",
"room_id": "!BnKuBPCvyfOkhcUjEu:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSt", {
type: "m.room.message",
@@ -2249,7 +2630,7 @@ test("event2message: editing a formatted body message", async t => {
},
"event_id": "$KxGwvVNzNcmlVbiI2m5kX-jMFNi3Jle71-uu1j7P7vM",
"room_id": "!BnKuBPCvyfOkhcUjEu:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs", {
type: "m.room.message",
@@ -2305,7 +2686,7 @@ test("event2message: rich reply to a matrix user's long message with formatting"
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", {
"type": "m.room.message",
@@ -2360,7 +2741,7 @@ test("event2message: rich reply to an image", async t => {
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", {
type: "m.room.message",
@@ -2422,7 +2803,7 @@ test("event2message: rich reply to a spoiler should ensure the spoiler is hidden
},
"event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
"room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", {
type: "m.room.message",
@@ -2473,7 +2854,7 @@ test("event2message: with layered rich replies, the preview should only be the r
},
event_id: "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", {
"type": "m.room.message",
@@ -2534,7 +2915,7 @@ test("event2message: if event is a reply and starts with a quote, they should be
},
room_id: "!TqlyQmifxGUggEmdBN:cadence.moe",
event_id: "$nCvtZeBFedYuEavt4OftloCHc0kaFW2ktHCfIOklhjU",
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$tTYQcke93fwocsc1K6itwUq85EG0RZ0ksCuIglKioks", {
sender: "@aflower:syndicated.gay",
@@ -2585,7 +2966,7 @@ test("event2message: rich reply to a deleted event", async t => {
},
event_id: "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
room_id: "!TqlyQmifxGUggEmdBN:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$f-noT-d-Eo_Xgpc05Ww89ErUXku4NwKWYGHLzWKo1kU", {
type: "m.room.message",
@@ -2643,7 +3024,7 @@ test("event2message: rich reply to a state event with no body", async t => {
},
event_id: "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
room_id: "!TqlyQmifxGUggEmdBN:cadence.moe"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$f-noT-d-Eo_Xgpc05Ww89ErUXku4NwKWYGHLzWKo1kU", {
type: "m.room.topic",
@@ -2671,6 +3052,99 @@ test("event2message: rich reply to a state event with no body", async t => {
)
})
+test("event2message: rich reply with an image", async t => {
+ let called = 0
+ t.deepEqual(
+ await eventToMessage({
+ type: "m.room.message",
+ sender: "@cadence:cadence.moe",
+ content: {
+ body: "image.png",
+ info: {
+ size: 470379,
+ mimetype: "image/png",
+ thumbnail_info: {
+ w: 800,
+ h: 450,
+ mimetype: "image/png",
+ size: 183014
+ },
+ w: 1920,
+ h: 1080,
+ "xyz.amorgan.blurhash": "L24_wtVt00xuxvR%NFX74Toz?waL",
+ thumbnail_url: "mxc://cadence.moe/lPtnjlleowWCXGOHKVDyoXGn"
+ },
+ msgtype: "m.image",
+ "m.relates_to": {
+ "m.in_reply_to": {
+ event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4"
+ }
+ },
+ url: "mxc://cadence.moe/yxMobQMbSqNHpajxgSHtaooG"
+ },
+ origin_server_ts: 1764127662631,
+ unsigned: {
+ membership: "join",
+ age: 97,
+ transaction_id: "m1764127662540.2"
+ },
+ event_id: "$QOxkw7u8vjTrrdKxEUO13JWSixV7UXAZU1freT1SkHc",
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
+ }, data.guild.general, data.channel.general, {
+ api: {
+ getEvent(roomID, eventID) {
+ called++
+ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
+ t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
+ return {
+ type: "m.room.message",
+ sender: "@cadence:cadence.moe",
+ content: {
+ msgtype: "m.text",
+ body: "you have to check every diff above insane on this set https://osu.ppy.sh/beatmapsets/2263303#osu/4826296"
+ },
+ origin_server_ts: 1763639396419,
+ unsigned: {
+ membership: "join",
+ age: 486586696,
+ transaction_id: "m1763639396324.578"
+ },
+ event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4",
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
+ }
+ }
+ }
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [
+ {
+ content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/112760669178241024/1128118177155526666 **Ⓜcadence [they]**: you have to check every diff above insane on this...",
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ },
+ attachments: [
+ {
+ filename: "image.png",
+ id: "0",
+ },
+ ],
+ avatar_url: undefined,
+ pendingFiles: [
+ {
+ mxc: "mxc://cadence.moe/yxMobQMbSqNHpajxgSHtaooG",
+ name: "image.png",
+ },
+ ],
+ username: "cadence [they]",
+ },
+ ]
+ }
+ )
+})
+
test("event2message: raw mentioning discord users in plaintext body works", async t => {
t.deepEqual(
await eventToMessage({
@@ -2873,6 +3347,47 @@ test("event2message: mentioning matrix users works", async t => {
)
})
+test("event2message: matrix mentions are not double-escaped when embed links permission is denied", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ msgtype: "m.text",
+ body: "wrong body",
+ format: "org.matrix.custom.html",
+ formatted_body: `I'm just ▲ testing mentions`
+ },
+ event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
+ origin_server_ts: 1688301929913,
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ type: "m.room.message",
+ unsigned: {
+ age: 405299
+ }
+ }, {
+ id: "123",
+ roles: [{
+ id: "123",
+ name: "@everyone",
+ permissions: DiscordTypes.PermissionFlagsBits.SendMessages
+ }]
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "I'm just [@▲]() testing mentions",
+ avatar_url: undefined,
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+})
+
test("event2message: multiple mentions are both escaped", async t => {
t.deepEqual(
await eventToMessage({
@@ -3021,7 +3536,7 @@ test("event2message: mentioning bridged rooms by alias works", async t => {
unsigned: {
age: 405299
}
- }, {}, {
+ }, {}, {}, {
api: {
async getAlias(alias) {
called++
@@ -3063,7 +3578,7 @@ test("event2message: mentioning bridged rooms by alias works (plaintext body)",
unsigned: {
age: 405299
}
- }, {}, {
+ }, {}, {}, {
api: {
async getAlias(alias) {
called++
@@ -3105,7 +3620,7 @@ test("event2message: mentioning bridged rooms by alias skips the link when alias
unsigned: {
age: 405299
}
- }, {}, {
+ }, {}, {}, {
api: {
async getAlias(alias) {
called++
@@ -3282,7 +3797,7 @@ test("event2message: mentioning unknown bridged events can approximate with time
unsigned: {
age: 405299
}
- }, {}, {
+ }, {}, {}, {
api: {
async getEvent(roomID, eventID) {
called++
@@ -3329,7 +3844,7 @@ test("event2message: mentioning events falls back to original link when server d
unsigned: {
age: 405299
}
- }, {}, {
+ }, {}, {}, {
api: {
async getEvent(roomID, eventID) {
called++
@@ -3375,7 +3890,7 @@ test("event2message: mentioning events falls back to original link when the chan
unsigned: {
age: 405299
}
- }, {}, {
+ }, {}, {}, {
api: {
/* c8 ignore next 3 */
async getEvent() {
@@ -3537,7 +4052,7 @@ test("event2message: caches the member if the member is not known", async t => {
unsigned: {
age: 405299
}
- }, {}, {
+ }, {}, {}, {
api: {
getStateEvent: async (roomID, type, stateKey) => {
called++
@@ -3587,7 +4102,7 @@ test("event2message: does not cache the member if the room is not known", async
unsigned: {
age: 405299
}
- }, {}, {
+ }, {}, {}, {
api: {
getStateEvent: async (roomID, type, stateKey) => {
called++
@@ -3635,7 +4150,7 @@ test("event2message: skips caching the member if the member does not exist, some
unsigned: {
age: 405299
}
- }, {}, {
+ }, {}, {}, {
api: {
getStateEvent: async (roomID, type, stateKey) => {
called++
@@ -3680,7 +4195,7 @@ test("event2message: overly long usernames are shifted into the message content"
unsigned: {
age: 405299
}
- }, {}, {
+ }, {}, {}, {
api: {
getStateEvent: async (roomID, type, stateKey) => {
called++
@@ -4041,6 +4556,166 @@ test("event2message: evil encrypted image attachment works", async t => {
)
})
+test("event2message: large attachments are uploaded if the server boost level is sufficient", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ type: "m.room.message",
+ sender: "@cadence:cadence.moe",
+ content: {
+ body: "cool cat.png",
+ filename: "cool cat.png",
+ info: {
+ size: 90_000_000,
+ mimetype: "image/png",
+ w: 480,
+ h: 480,
+ "xyz.amorgan.blurhash": "URTHsVaTpdj2eKZgkkkXp{pHl7feo@lSl9Z$"
+ },
+ msgtype: "m.image",
+ url: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"
+ },
+ event_id: "$CXQy3Wmg1A-gL_xAesC1HQcQTEXwICLdSwwUx55FBTI",
+ room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
+ }, {
+ features: ["MAX_FILE_SIZE_100_MB"]
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "",
+ avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
+ attachments: [{id: "0", filename: "cool cat.png"}],
+ pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}]
+ }]
+ }
+ )
+})
+
+test("event2message: files too large for Discord are linked as as URL", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ type: "m.room.message",
+ sender: "@cadence:cadence.moe",
+ content: {
+ body: "cool cat.png",
+ filename: "cool cat.png",
+ info: {
+ size: 40_000_000,
+ mimetype: "image/png",
+ w: 480,
+ h: 480,
+ "xyz.amorgan.blurhash": "URTHsVaTpdj2eKZgkkkXp{pHl7feo@lSl9Z$"
+ },
+ msgtype: "m.image",
+ url: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"
+ },
+ event_id: "$CXQy3Wmg1A-gL_xAesC1HQcQTEXwICLdSwwUx55FBTI",
+ room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "🖼️ _Uploaded file: [cool cat.png](https://bridge.example.org/download/matrix/cadence.moe/IvxVJFLEuksCNnbojdSIeEvn) (40 MB)_",
+ avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+})
+
+test("event2message: files too large for Discord can have a plaintext caption", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ type: "m.room.message",
+ sender: "@cadence:cadence.moe",
+ content: {
+ body: "Cat emoji surrounded by pink hearts",
+ filename: "cool cat.png",
+ info: {
+ size: 40_000_000,
+ mimetype: "image/png",
+ w: 480,
+ h: 480,
+ "xyz.amorgan.blurhash": "URTHsVaTpdj2eKZgkkkXp{pHl7feo@lSl9Z$"
+ },
+ msgtype: "m.image",
+ url: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"
+ },
+ event_id: "$CXQy3Wmg1A-gL_xAesC1HQcQTEXwICLdSwwUx55FBTI",
+ room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "Cat emoji surrounded by pink hearts\n🖼️ _Uploaded file: [cool cat.png](https://bridge.example.org/download/matrix/cadence.moe/IvxVJFLEuksCNnbojdSIeEvn) (40 MB)_",
+ avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+})
+
+test("event2message: files too large for Discord can have a formatted caption", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ body: "this event has `formatting`",
+ filename: "5740.jpg",
+ format: "org.matrix.custom.html",
+ formatted_body: "this event has formatting",
+ info: {
+ h: 1340,
+ mimetype: "image/jpeg",
+ size: 40_000_000,
+ thumbnail_info: {
+ h: 670,
+ mimetype: "image/jpeg",
+ size: 80157,
+ w: 540
+ },
+ thumbnail_url: "mxc://thomcat.rocks/XhLsOCDBYyearsLQgUUrbAvw",
+ w: 1080,
+ "xyz.amorgan.blurhash": "KHJQG*55ic-.}?0M58J.9v"
+ },
+ msgtype: "m.image",
+ url: "mxc://thomcat.rocks/RTHsXmcMPXmuHqVNsnbKtRbh"
+ },
+ origin_server_ts: 1740607766895,
+ sender: "@cadence:cadence.moe",
+ type: "m.room.message",
+ event_id: "$NqNqVgukiQm1nynm9vIr9FIq31hZpQ3udOd7cBIW46U",
+ room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe"
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "this event has `formatting`\n🖼️ _Uploaded file: [5740.jpg](https://bridge.example.org/download/matrix/thomcat.rocks/RTHsXmcMPXmuHqVNsnbKtRbh) (40 MB)_",
+ avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+})
+
+
test("event2message: stickers work", async t => {
t.deepEqual(
await eventToMessage({
@@ -4093,7 +4768,7 @@ test("event2message: stickers fetch mimetype from server when mimetype not provi
},
event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
- }, {}, {
+ }, {}, {}, {
api: {
async getMedia(mxc, options) {
called++
@@ -4136,7 +4811,7 @@ test("event2message: stickers with unknown mimetype are not allowed", async t =>
},
event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
- }, {}, {
+ }, {}, {}, {
api: {
async getMedia(mxc, options) {
called++
@@ -4300,7 +4975,7 @@ test("event2message: guessed @mentions in plaintext may join members to mention"
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}, {
id: "112760669178241024"
- }, {
+ }, {}, {
snow: {
guild: {
async searchGuildMembers(guildID, options) {
@@ -4353,7 +5028,7 @@ test("event2message: guessed @mentions in formatted body may join members to men
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}, {
id: "112760669178241024"
- }, {
+ }, {}, {
snow: {
guild: {
async searchGuildMembers(guildID, options) {
@@ -4397,7 +5072,7 @@ test("event2message: guessed @mentions feature will not activate on links or cod
},
event_id: "$u5gSwSzv_ZQS3eM00mnTBCor8nx_A_AwuQz7e59PZk8",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
- }, {}, {
+ }, {}, {}, {
snow: {
guild: {
/* c8 ignore next 4 */
@@ -4466,9 +5141,9 @@ test("event2message: @room converts to @everyone and is allowed when the room do
},
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
- getStateEvent(roomID, type, key) {
+ async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(type, "m.room.power_levels")
@@ -4479,6 +5154,19 @@ test("event2message: @room converts to @everyone and is allowed when the room do
room: 0
}
}
+ },
+ async getStateEventOuter(roomID, type, key) {
+ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
+ t.equal(type, "m.room.create")
+ t.equal(key, "")
+ return {
+ type: "m.room.create",
+ state_key: "",
+ sender: "@_ooye_bot:cadence.moe",
+ content: {
+ room_version: "11"
+ }
+ }
}
}
}),
@@ -4499,7 +5187,6 @@ test("event2message: @room converts to @everyone and is allowed when the room do
})
test("event2message: @room converts to @everyone but is not allowed when the room restricts who can use it", async t => {
- let called = 0
t.deepEqual(
await eventToMessage({
type: "m.room.message",
@@ -4512,10 +5199,9 @@ test("event2message: @room converts to @everyone but is not allowed when the roo
},
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
- getStateEvent(roomID, type, key) {
- called++
+ async getStateEvent(roomID, type, key) {
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
@@ -4525,6 +5211,19 @@ test("event2message: @room converts to @everyone but is not allowed when the roo
room: 20
}
}
+ },
+ async getStateEventOuter(roomID, type, key) {
+ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
+ t.equal(type, "m.room.create")
+ t.equal(key, "")
+ return {
+ type: "m.room.create",
+ state_key: "",
+ sender: "@_ooye_bot:cadence.moe",
+ content: {
+ room_version: "11"
+ }
+ }
}
}
}),
@@ -4545,7 +5244,6 @@ test("event2message: @room converts to @everyone but is not allowed when the roo
})
test("event2message: @room converts to @everyone and is allowed if the user has sufficient power to use it", async t => {
- let called = 0
t.deepEqual(
await eventToMessage({
type: "m.room.message",
@@ -4558,10 +5256,9 @@ test("event2message: @room converts to @everyone and is allowed if the user has
},
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A"
- }, data.guild.general, {
+ }, data.guild.general, data.channel.general, {
api: {
- getStateEvent(roomID, type, key) {
- called++
+ async getStateEvent(roomID, type, key) {
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
@@ -4573,6 +5270,19 @@ test("event2message: @room converts to @everyone and is allowed if the user has
room: 20
}
}
+ },
+ async getStateEventOuter(roomID, type, key) {
+ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
+ t.equal(type, "m.room.create")
+ t.equal(key, "")
+ return {
+ type: "m.room.create",
+ state_key: "",
+ sender: "@_ooye_bot:cadence.moe",
+ content: {
+ room_version: "11"
+ }
+ }
}
}
}),
@@ -4658,102 +5368,122 @@ test("event2message: table", async t => {
)
})
-slow()("event2message: unknown emoji at the end is reuploaded as a sprite sheet", async t => {
- const messages = await eventToMessage({
- type: "m.room.message",
- sender: "@cadence:cadence.moe",
- content: {
- msgtype: "m.text",
- body: "wrong body",
- format: "org.matrix.custom.html",
- formatted_body: 'a b
'
- },
- event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
- room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
- }, {}, {mxcDownloader: mockGetAndConvertEmoji})
- const testResult = {
- content: messages.messagesToSend[0].content,
- fileName: messages.messagesToSend[0].pendingFiles[0].name,
- fileContentStart: messages.messagesToSend[0].pendingFiles[0].buffer.subarray(0, 90).toString("base64")
- }
- t.deepEqual(testResult, {
- content: "a b",
- fileName: "emojis.png",
- fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALoklEQVR4nM1ZaVBU2RU+LZSIGnAvFUtcRkSk6abpbkDH"
- })
+test("event2message: unknown emoji at the end is used for sprite sheet", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ type: "m.room.message",
+ sender: "@cadence:cadence.moe",
+ content: {
+ msgtype: "m.text",
+ body: "wrong body",
+ format: "org.matrix.custom.html",
+ formatted_body: 'a b
'
+ },
+ event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
+ }),
+ {
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "a b [\u2800](https://bridge.example.org/download/sheet?e=cadence.moe%2FRLMgJGfgTPjIQtvvWZsYjhjy)",
+ avatar_url: undefined,
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }],
+ ensureJoined: []
+ }
+ )
})
-slow()("event2message: known emoji from an unreachable server at the end is reuploaded as a sprite sheet", async t => {
- const messages = await eventToMessage({
- type: "m.room.message",
- sender: "@cadence:cadence.moe",
- content: {
- msgtype: "m.text",
- body: "wrong body",
- format: "org.matrix.custom.html",
- formatted_body: 'a b
'
- },
- event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
- room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
- }, {}, {mxcDownloader: mockGetAndConvertEmoji})
- const testResult = {
- content: messages.messagesToSend[0].content,
- fileName: messages.messagesToSend[0].pendingFiles[0].name,
- fileContentStart: messages.messagesToSend[0].pendingFiles[0].buffer.subarray(0, 90).toString("base64")
- }
- t.deepEqual(testResult, {
- content: "a b",
- fileName: "emojis.png",
- fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAOoUlEQVR4nM1aCXBbx3l+Eu8bN0CAuO+TAHGTFAmAJHgT"
- })
+test("event2message: known emoji from an unreachable server at the end is used for sprite sheet", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ type: "m.room.message",
+ sender: "@cadence:cadence.moe",
+ content: {
+ msgtype: "m.text",
+ body: "wrong body",
+ format: "org.matrix.custom.html",
+ formatted_body: 'a b
'
+ },
+ event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
+ }),
+ {
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "a b [\u2800](https://bridge.example.org/download/sheet?e=cadence.moe%2FbZFuuUSEebJYXUMSxuuSuLTa)",
+ avatar_url: undefined,
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }],
+ ensureJoined: []
+ }
+ )
})
-slow()("event2message: known and unknown emojis in the end are reuploaded as a sprite sheet", async t => {
- const messages = await eventToMessage({
- type: "m.room.message",
- sender: "@cadence:cadence.moe",
- content: {
- msgtype: "m.text",
- body: "wrong body",
- format: "org.matrix.custom.html",
- formatted_body: 'known unknown:
and known unknown:
'
- },
- event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
- room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
- }, {}, {mxcDownloader: mockGetAndConvertEmoji})
- const testResult = {
- content: messages.messagesToSend[0].content,
- fileName: messages.messagesToSend[0].pendingFiles[0].name,
- fileContentStart: messages.messagesToSend[0].pendingFiles[0].buffer.subarray(0, 90).toString("base64")
- }
- t.deepEqual(testResult, {
- content: "known unknown: <:hippo:230201364309868544> [:ms_robot_dress:](https://bridge.example.org/download/matrix/cadence.moe/wcouHVjbKJJYajkhJLsyeJAA) and known unknown:",
- fileName: "emojis.png",
- fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAAGAAAAAwCAYAAADuFn/PAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAeXRFWHRSYXcACklQVEMgcHJvZmlsZQogICAgICA0Ngoz"
- })
+test("event2message: known and unknown emojis in the end are used for sprite sheet", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ type: "m.room.message",
+ sender: "@cadence:cadence.moe",
+ content: {
+ msgtype: "m.text",
+ body: "wrong body",
+ format: "org.matrix.custom.html",
+ formatted_body: 'known unknown:
and known unknown:
'
+ },
+ event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
+ }),
+ {
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "known unknown: <:hippo:230201364309868544> [:ms_robot_dress:](https://bridge.example.org/download/matrix/cadence.moe/wcouHVjbKJJYajkhJLsyeJAA) and known unknown: [\u2800](https://bridge.example.org/download/sheet?e=cadence.moe%2FWbYqNlACRuicynBfdnPYtmvc&e=cadence.moe%2FHYcztccFIPgevDvoaWNsEtGJ)",
+ avatar_url: undefined,
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }],
+ ensureJoined: []
+ }
+ )
})
-slow()("event2message: all unknown chess emojis are reuploaded as a sprite sheet", async t => {
- const messages = await eventToMessage({
- type: "m.room.message",
- sender: "@cadence:cadence.moe",
- content: {
- msgtype: "m.text",
- body: "testing :chess_good_move::chess_incorrect::chess_blund::chess_brilliant_move::chess_blundest::chess_draw_black::chess_good_move::chess_incorrect::chess_blund::chess_brilliant_move::chess_blundest::chess_draw_black:",
- format: "org.matrix.custom.html",
- formatted_body: "testing 










"
- },
- event_id: "$Me6iE8C8CZyrDEOYYrXKSYRuuh_25Jj9kZaNrf7LKr4",
- room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
- }, {}, {mxcDownloader: mockGetAndConvertEmoji})
- const testResult = {
- content: messages.messagesToSend[0].content,
- fileName: messages.messagesToSend[0].pendingFiles[0].name,
- fileContentStart: messages.messagesToSend[0].pendingFiles[0].buffer.subarray(0, 90).toString("base64")
- }
- t.deepEqual(testResult, {
- content: "testing",
- fileName: "emojis.png",
- fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAAYAAAABgCAYAAAAU9KWJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48"
- })
+test("event2message: all unknown chess emojis are used for sprite sheet", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ type: "m.room.message",
+ sender: "@cadence:cadence.moe",
+ content: {
+ msgtype: "m.text",
+ body: "testing :chess_good_move::chess_incorrect::chess_blund::chess_brilliant_move::chess_blundest::chess_draw_black::chess_good_move::chess_incorrect::chess_blund::chess_brilliant_move::chess_blundest::chess_draw_black:",
+ format: "org.matrix.custom.html",
+ formatted_body: "testing 










"
+ },
+ event_id: "$Me6iE8C8CZyrDEOYYrXKSYRuuh_25Jj9kZaNrf7LKr4",
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
+ }),
+ {
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "testing [\u2800](https://bridge.example.org/download/sheet?e=cadence.moe%2FlHfmJpzgoNyNtYHdAmBHxXix&e=cadence.moe%2FMtRdXixoKjKKOyHJGWLsWLNU&e=cadence.moe%2FHXfFuougamkURPPMflTJRxGc&e=cadence.moe%2FikYKbkhGhMERAuPPbsnQzZiX&e=cadence.moe%2FAYPpqXzVJvZdzMQJGjioIQBZ&e=cadence.moe%2FUVuzvpVUhqjiueMxYXJiFEAj&e=cadence.moe%2FlHfmJpzgoNyNtYHdAmBHxXix&e=cadence.moe%2FMtRdXixoKjKKOyHJGWLsWLNU&e=cadence.moe%2FHXfFuougamkURPPMflTJRxGc&e=cadence.moe%2FikYKbkhGhMERAuPPbsnQzZiX&e=cadence.moe%2FAYPpqXzVJvZdzMQJGjioIQBZ&e=cadence.moe%2FUVuzvpVUhqjiueMxYXJiFEAj)",
+ avatar_url: undefined,
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }],
+ ensureJoined: []
+ }
+ )
})
diff --git a/src/m2d/converters/poll-components.js b/src/m2d/converters/poll-components.js
new file mode 100644
index 00000000..a8233e08
--- /dev/null
+++ b/src/m2d/converters/poll-components.js
@@ -0,0 +1,227 @@
+// @ts-check
+
+const assert = require("assert").strict
+const DiscordTypes = require("discord-api-types/v10")
+const {sync, db, discord, select, from} = require("../../passthrough")
+
+/** @type {import("../actions/setup-emojis")} */
+const setupEmojis = sync.require("../actions/setup-emojis")
+
+/**
+ * @param {{count: number}[]} topAnswers
+ * @param {number} count
+ * @returns {string}
+ */
+function getMedal(topAnswers, count) {
+ const winningOrTied = count && topAnswers[0].count === count
+ const secondOrTied = !winningOrTied && count && topAnswers[1]?.count === count && topAnswers.slice(-1)[0].count !== count
+ const thirdOrTied = !winningOrTied && !secondOrTied && count && topAnswers[2]?.count === count && topAnswers.slice(-1)[0].count !== count
+ const medal =
+ ( winningOrTied ? "🥇"
+ : secondOrTied ? "🥈"
+ : thirdOrTied ? "🥉"
+ : "")
+ return medal
+}
+
+/**
+ * @param {boolean} isClosed
+ * @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly
+ * @returns {DiscordTypes.APIMessageTopLevelComponent[]}
+*/
+function optionsToComponents(isClosed, pollOptions) {
+ const topAnswers = pollOptions.toSorted((a, b) => b.count - a.count)
+ /** @type {DiscordTypes.APIMessageTopLevelComponent[]} */
+ return pollOptions.map(option => {
+ const medal = getMedal(topAnswers, option.count)
+ return {
+ type: DiscordTypes.ComponentType.Container,
+ components: [{
+ type: DiscordTypes.ComponentType.Section,
+ components: [{
+ type: DiscordTypes.ComponentType.TextDisplay,
+ content: medal && isClosed ? `${medal} ${option.option_text}` : option.option_text
+ }],
+ accessory: {
+ type: DiscordTypes.ComponentType.Button,
+ style: medal === "🥇" && isClosed ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary,
+ label: option.count.toString(),
+ custom_id: `POLL_OPTION#${option.matrix_option}`,
+ disabled: isClosed
+ }
+ }]
+ }
+ })
+}
+
+/**
+ * @param {number} maxSelections
+ * @param {number} optionCount
+ */
+function getMultiSelectString(maxSelections, optionCount) {
+ if (maxSelections === 1) {
+ return "Select one answer"
+ } else if (maxSelections >= optionCount) {
+ return "Select one or more answers"
+ } else {
+ return `Select up to ${maxSelections} answers`
+ }
+}
+
+/**
+ * @param {number} maxSelections
+ * @param {number} optionCount
+ */
+function getMultiSelectClosedString(maxSelections, optionCount) {
+ if (maxSelections === 1) {
+ return "Single choice"
+ } else if (maxSelections >= optionCount) {
+ return "Multiple choice"
+ } else {
+ return `Multiple choice (up to ${maxSelections})`
+ }
+}
+
+/**
+ * @param {boolean} isClosed
+ * @param {number} maxSelections
+ * @param {string} questionText
+ * @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly
+ * @returns {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody}
+ */
+function getPollComponents(isClosed, maxSelections, questionText, pollOptions) {
+ /** @type {DiscordTypes.APIMessageTopLevelComponent[]} array because it can move around */
+ const multiSelectInfoComponent = [{
+ type: DiscordTypes.ComponentType.TextDisplay,
+ content: isClosed ? `-# ${getMultiSelectClosedString(maxSelections, pollOptions.length)}` : `-# ${getMultiSelectString(maxSelections, pollOptions.length)}`
+ }]
+ /** @type {DiscordTypes.APIMessageTopLevelComponent} */
+ let headingComponent
+ if (isClosed) {
+ headingComponent = { // This one is for the poll heading.
+ type: DiscordTypes.ComponentType.Section,
+ components: [
+ {
+ type: DiscordTypes.ComponentType.TextDisplay,
+ content: `## ${questionText}`
+ }
+ ],
+ accessory: {
+ type: DiscordTypes.ComponentType.Button,
+ style: DiscordTypes.ButtonStyle.Secondary,
+ custom_id: "POLL_VOTE",
+ label: "Voting closed",
+ disabled: true
+ }
+ }
+ } else {
+ headingComponent = { // This one is for the poll heading.
+ type: DiscordTypes.ComponentType.Section,
+ components: [
+ {
+ type: DiscordTypes.ComponentType.TextDisplay,
+ content: `## ${questionText}`
+ },
+ // @ts-ignore
+ multiSelectInfoComponent.pop()
+ ],
+ accessory: {
+ type: DiscordTypes.ComponentType.Button,
+ style: DiscordTypes.ButtonStyle.Primary,
+ custom_id: "POLL_VOTE",
+ label: "Vote!"
+ }
+ }
+ }
+ const optionComponents = optionsToComponents(isClosed, pollOptions)
+ return {
+ flags: DiscordTypes.MessageFlags.IsComponentsV2,
+ components: [headingComponent, ...optionComponents, ...multiSelectInfoComponent]
+ }
+}
+
+/** @param {string} messageID */
+function getPollComponentsFromDatabase(messageID) {
+ const pollRow = select("poll", ["max_selections", "is_closed", "question_text"], {message_id: messageID}).get()
+ assert(pollRow)
+ /** @type {{matrix_option: string, option_text: string, count: number}[]} */
+ const pollResults = db.prepare("SELECT matrix_option, option_text, seq, count(discord_or_matrix_user_id) as count FROM poll_option LEFT JOIN poll_vote USING (message_id, matrix_option) WHERE message_id = ? GROUP BY matrix_option ORDER BY seq").all(messageID)
+ return getPollComponents(!!pollRow.is_closed, pollRow.max_selections, pollRow.question_text, pollResults)
+}
+
+/**
+ * @param {string} channelID
+ * @param {string} messageID
+ * @param {string} questionText
+ * @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly
+ * @returns {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody}
+ */
+function getPollEndMessage(channelID, messageID, questionText, pollOptions) {
+ const topAnswers = pollOptions.toSorted((a, b) => b.count - a.count)
+ const totalVotes = pollOptions.reduce((a, c) => a + c.count, 0)
+ const tied = topAnswers[0].count === topAnswers[1].count
+ const titleString = `-# The poll **${questionText}** has closed.`
+ let winnerString = ""
+ let resultsString = ""
+ if (totalVotes == 0) {
+ winnerString = "There was no winner"
+ } else if (tied) {
+ winnerString = "It's a draw!"
+ resultsString = `${Math.round((topAnswers[0].count/totalVotes)*100)}%`
+ } else {
+ const pollWin = select("auto_emoji", ["name", "emoji_id"], {name: "poll_win"}).get()
+ winnerString = `${topAnswers[0].option_text} <:${pollWin?.name}:${pollWin?.emoji_id}>`
+ resultsString = `Winning answer • ${Math.round((topAnswers[0].count/totalVotes)*100)}%`
+ }
+ // @ts-ignore
+ const guildID = discord.channels.get(channelID).guild_id
+ let mainContent = `**${winnerString}**`
+ if (resultsString) {
+ mainContent += `\n-# ${resultsString}`
+ }
+ return {
+ flags: DiscordTypes.MessageFlags.IsComponentsV2,
+ components: [{
+ type: DiscordTypes.ComponentType.TextDisplay,
+ content: titleString
+ }, {
+ type: DiscordTypes.ComponentType.Container,
+ components: [{
+ type: DiscordTypes.ComponentType.Section,
+ components: [{
+ type: DiscordTypes.ComponentType.TextDisplay,
+ content: `**${winnerString}**\n-# ${resultsString}`
+ }],
+ accessory: {
+ type: DiscordTypes.ComponentType.Button,
+ style: DiscordTypes.ButtonStyle.Link,
+ url: `https://discord.com/channels/${guildID}/${channelID}/${messageID}`,
+ label: "View Poll"
+ }
+ }]
+ }]
+ }
+}
+
+/**
+ * @param {string} channelID
+ * @param {string} messageID
+ */
+async function getPollEndMessageFromDatabase(channelID, messageID) {
+ const pollWin = select("auto_emoji", ["name", "emoji_id"], {name: "poll_win"}).get()
+ if (!pollWin) {
+ await setupEmojis.setupEmojis()
+ }
+
+ const pollRow = select("poll", ["max_selections", "question_text"], {message_id: messageID}).get()
+ assert(pollRow)
+ /** @type {{matrix_option: string, option_text: string, count: number}[]} */
+ const pollResults = db.prepare("SELECT matrix_option, option_text, seq, count(discord_or_matrix_user_id) as count FROM poll_option LEFT JOIN poll_vote USING (message_id, matrix_option) WHERE message_id = ? GROUP BY matrix_option ORDER BY seq").all(messageID)
+ return getPollEndMessage(channelID, messageID, pollRow.question_text, pollResults)
+}
+
+module.exports.getMultiSelectString = getMultiSelectString
+module.exports.getPollComponents = getPollComponents
+module.exports.getPollComponentsFromDatabase = getPollComponentsFromDatabase
+module.exports.getPollEndMessageFromDatabase = getPollEndMessageFromDatabase
+module.exports.getMedal = getMedal
diff --git a/src/m2d/converters/utils.js b/src/m2d/converters/utils.js
deleted file mode 100644
index 41cb0af0..00000000
--- a/src/m2d/converters/utils.js
+++ /dev/null
@@ -1,241 +0,0 @@
-// @ts-check
-
-const assert = require("assert").strict
-
-const passthrough = require("../../passthrough")
-const {db} = passthrough
-
-const {reg} = require("../../matrix/read-registration")
-const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
-
-/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
-let hasher = null
-// @ts-ignore
-require("xxhash-wasm")().then(h => hasher = h)
-
-const BLOCK_ELEMENTS = [
- "ADDRESS", "ARTICLE", "ASIDE", "AUDIO", "BLOCKQUOTE", "BODY", "CANVAS",
- "CENTER", "DD", "DETAILS", "DIR", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE",
- "FOOTER", "FORM", "FRAMESET", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER",
- "HGROUP", "HR", "HTML", "ISINDEX", "LI", "MAIN", "MENU", "NAV", "NOFRAMES",
- "NOSCRIPT", "OL", "OUTPUT", "P", "PRE", "SECTION", "SUMMARY", "TABLE", "TBODY", "TD",
- "TFOOT", "TH", "THEAD", "TR", "UL"
-]
-const NEWLINE_ELEMENTS = BLOCK_ELEMENTS.concat(["BR"])
-
-/**
- * Determine whether an event is the bridged representation of a discord message.
- * Such messages shouldn't be bridged again.
- * @param {string} sender
- */
-function eventSenderIsFromDiscord(sender) {
- // If it's from a user in the bridge's namespace, then it originated from discord
- // This could include messages sent by the appservice's bot user, because that is what's used for webhooks
- if (userRegex.some(x => sender.match(x))) {
- return true
- }
-
- return false
-}
-
-/**
- * Event IDs are really big and have more entropy than we need.
- * If we want to store the event ID in the database, we can store a more compact version by hashing it with this.
- * I choose a 64-bit non-cryptographic hash as only a 32-bit hash will see birthday collisions unreasonably frequently: https://en.wikipedia.org/wiki/Birthday_attack#Mathematics
- * xxhash outputs an unsigned 64-bit integer.
- * Converting to a signed 64-bit integer with no bit loss so that it can be stored in an SQLite integer field as-is: https://www.sqlite.org/fileformat2.html#record_format
- * This should give very efficient storage with sufficient entropy.
- * @param {string} eventID
- */
-function getEventIDHash(eventID) {
- assert(hasher, "xxhash is not ready yet")
- if (eventID[0] === "$" && eventID.length >= 13) {
- eventID = eventID.slice(1) // increase entropy per character to potentially help xxhash
- }
- const unsignedHash = hasher.h64(eventID)
- const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
- return signedHash
-}
-
-class MatrixStringBuilder {
- constructor() {
- this.body = ""
- this.formattedBody = ""
- }
-
- /**
- * @param {string} body
- * @param {string} [formattedBody]
- * @param {any} [condition]
- */
- add(body, formattedBody, condition = true) {
- if (condition) {
- if (formattedBody == undefined) formattedBody = body
- this.body += body
- this.formattedBody += formattedBody
- }
- return this
- }
-
- /**
- * @param {string} body
- * @param {string} [formattedBody]
- * @param {any} [condition]
- */
- addLine(body, formattedBody, condition = true) {
- if (condition) {
- if (formattedBody == undefined) formattedBody = body
- if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n"
- this.body += body
- const match = this.formattedBody.match(/<\/?([a-zA-Z]+[a-zA-Z0-9]*)[^>]*>\s*$/)
- if (this.formattedBody.length && (!match || !NEWLINE_ELEMENTS.includes(match[1].toUpperCase()))) this.formattedBody += "
"
- this.formattedBody += formattedBody
- }
- return this
- }
-
- /**
- * @param {string} body
- * @param {string} [formattedBody]
- * @param {any} [condition]
- */
- addParagraph(body, formattedBody, condition = true) {
- if (condition) {
- if (formattedBody == undefined) formattedBody = body
- if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n\n"
- this.body += body
- formattedBody = `${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} */
- const servers = new Map()
- // We can get the most popular servers if we know the members, so let's process those...
- Object.keys(joined)
- .filter(mxid => !mxid.startsWith("@_")) // Quick check
- .filter(mxid => !userRegex.some(r => mxid.match(r))) // Full check
- .slice(0, 1000) // Just sample the first thousand real members
- .map(mxid => {
- const match = mxid.match(/:(.*)/)
- assert(match)
- return match[1]
- })
- .filter(server => !server.match(/([a-f0-9:]+:+)+[a-f0-9]+/)) // No IPv6 servers
- .filter(server => !server.match(/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/)) // No IPv4 servers
- // I don't care enough to check ACLs
- .forEach(server => {
- const existing = servers.get(server)
- if (!existing) servers.set(server, 1)
- else servers.set(server, existing + 1)
- })
- const serverList = [...servers.entries()].sort((a, b) => b[1] - a[1])
- for (const server of serverList) {
- if (!candidates.includes(server[0])) {
- candidates.push(server[0])
- if (candidates.length >= 4) break // Can have at most 4 candidate via servers
- }
- }
- return candidates
-}
-
-/**
- * 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
- * @returns {Promise}
- */
-async function getViaServersQuery(roomID, api) {
- const list = await getViaServers(roomID, api)
- const qs = new URLSearchParams()
- for (const server of list) {
- qs.append("via", server)
- }
- return qs
-}
-
-/**
- * Since the introduction of authenticated media, this can no longer just be the /_matrix/media/r0/download URL
- * because Discord and Discord users cannot use those URLs. Media now has to be proxied through the bridge.
- * To avoid the bridge acting as a proxy for *any* media, there is a list of permitted media stored in the database.
- * (The other approach would be signing the URLs with a MAC (or similar) and adding the signature, but I'm not a
- * cryptographer, so I don't want to.) To reduce database disk space usage, instead of storing each permitted URL,
- * we just store its xxhash as a signed (as in +/-, not signature) 64-bit integer, which fits in an SQLite integer field.
- * @see https://matrix.org/blog/2024/06/26/sunsetting-unauthenticated-media/ background
- * @see https://matrix.org/blog/2024/06/20/matrix-v1.11-release/ implementation details
- * @see https://www.sqlite.org/fileformat2.html#record_format SQLite integer field size
- * @param {string} mxc
- * @returns {string | undefined}
- */
-function getPublicUrlForMxc(mxc) {
- assert(hasher, "xxhash is not ready yet")
- const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
- if (!mediaParts) return undefined
-
- const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}`
- const unsignedHash = hasher.h64(serverAndMediaID)
- const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
- db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash)
-
- return `${reg.ooye.bridge_origin}/download/matrix/${serverAndMediaID}`
-}
-
-module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS
-module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
-module.exports.getPublicUrlForMxc = getPublicUrlForMxc
-module.exports.getEventIDHash = getEventIDHash
-module.exports.MatrixStringBuilder = MatrixStringBuilder
-module.exports.getViaServers = getViaServers
-module.exports.getViaServersQuery = getViaServersQuery
diff --git a/src/m2d/converters/utils.test.js b/src/m2d/converters/utils.test.js
deleted file mode 100644
index 650f4201..00000000
--- a/src/m2d/converters/utils.test.js
+++ /dev/null
@@ -1,178 +0,0 @@
-// @ts-check
-
-const e = new Error("Custom error")
-
-const {test} = require("supertape")
-const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers} = require("./utils")
-const util = require("util")
-
-/** @param {string[]} mxids */
-function joinedList(mxids) {
- /** @type {{[mxid: string]: {display_name: null, avatar_url: null}}} */
- const joined = {}
- for (const mxid of mxids) {
- joined[mxid] = {
- display_name: null,
- avatar_url: null
- }
- }
- return {joined}
-}
-
-test("sender type: matrix user", t => {
- t.notOk(eventSenderIsFromDiscord("@cadence:cadence.moe"))
-})
-
-test("sender type: ooye bot", t => {
- t.ok(eventSenderIsFromDiscord("@_ooye_bot:cadence.moe"))
-})
-
-test("sender type: ooye puppet", t => {
- t.ok(eventSenderIsFromDiscord("@_ooye_sheep:cadence.moe"))
-})
-
-test("event hash: hash is the same each time", t => {
- const eventID = "$example"
- t.equal(getEventIDHash(eventID), getEventIDHash(eventID))
-})
-
-test("event hash: hash is different for different inputs", t => {
- t.notEqual(getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe1"), getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe2"))
-})
-
-test("MatrixStringBuilder: add, addLine, add same text", t => {
- const gatewayMessage = {t: "MY_MESSAGE", d: {display: "Custom message data"}}
- let stackLines = e.stack?.split("\n")
-
- const builder = new MatrixStringBuilder()
- builder.addLine("\u26a0 Bridged event from Discord not delivered", "\u26a0 Bridged event from Discord not delivered")
- builder.addLine(`Gateway event: ${gatewayMessage.t}`)
- builder.addLine(e.toString())
- if (stackLines) {
- stackLines = stackLines.slice(0, 2)
- stackLines[1] = stackLines[1].replace(/\\/g, "/").replace(/(\s*at ).*(\/m2d\/)/, "$1.$2")
- builder.addLine(`Error trace:`, `Error trace
`)
- builder.add(`\n${stackLines.join("\n")}`, `${stackLines.join("\n")}`)
- }
- builder.addLine("", `Original payload
${util.inspect(gatewayMessage.d, false, 4, false)}`)
-
- t.deepEqual(builder.get(), {
- msgtype: "m.text",
- body: "\u26a0 Bridged event from Discord not delivered"
- + "\nGateway event: MY_MESSAGE"
- + "\nError: Custom error"
- + "\nError trace:"
- + "\nError: Custom error"
- + "\n at ./m2d/converters/utils.test.js:3:11)\n",
- format: "org.matrix.custom.html",
- formatted_body: "\u26a0 Bridged event from Discord not delivered"
- + "
Gateway event: MY_MESSAGE"
- + "
Error: Custom error"
- + "
Error trace
Error: Custom error\n at ./m2d/converters/utils.test.js:3:11)
"
- + `Original payload
{ display: 'Custom message data' }`
- })
-})
-
-test("MatrixStringBuilder: complete code coverage", t => {
- const builder = new MatrixStringBuilder()
- builder.add("Line 1")
- builder.addParagraph("Line 2")
- builder.add("Line 3")
- builder.addParagraph("Line 4")
-
- t.deepEqual(builder.get(), {
- msgtype: "m.text",
- body: "Line 1\n\nLine 2Line 3\n\nLine 4",
- format: "org.matrix.custom.html",
- formatted_body: "Line 1Line 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 ce3638c1..70e293b1 100644
--- a/src/m2d/event-dispatcher.js
+++ b/src/m2d/event-dispatcher.js
@@ -7,6 +7,8 @@
const util = require("util")
const Ty = require("../types")
const {discord, db, sync, as, select} = require("../passthrough")
+const {tag} = require("@cloudrac3r/html-template-tag")
+const {Semaphore} = require("@chriscdn/promise-semaphore")
/** @type {import("./actions/send-event")} */
const sendEvent = sync.require("./actions/send-event")
@@ -16,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
@@ -80,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
@@ -88,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()
@@ -121,10 +135,10 @@ async function sendError(roomID, source, type, e, payload) {
// Where
const stack = stringifyErrorStack(e)
- builder.addLine(`Error trace:\n${stack}`, `Error trace
${stack}`)
+ builder.addLine(`Error trace:\n${stack}`, tag`Error trace
${stack}`)
// How
- builder.addLine("", `Original payload
${util.inspect(payload, false, 4, false)}`)
+ builder.addLine("", tag`Original payload
${util.inspect(payload, false, 4, false)}`)
}
// Send
@@ -152,34 +166,37 @@ function guard(type, fn) {
}
}
+const errorRetrySema = new Semaphore()
+
/**
* @param {Ty.Event.Outer} reactionEvent
*/
async function onRetryReactionAdd(reactionEvent) {
const roomID = reactionEvent.room_id
- const event = await api.getEvent(roomID, reactionEvent.content["m.relates_to"]?.event_id)
+ 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
- const error = event.content["moe.cadence.ooye.error"]
- if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return
+ // Check that it's a real error from OOYE
+ const error = event.content["moe.cadence.ooye.error"]
+ if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return
- // To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator
- if (reactionEvent.sender !== event.sender) {
- // Check if it's a room moderator
- const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
- const powerLevel = powerLevelsStateContent.users?.[reactionEvent.sender] || 0
- if (powerLevel < 50) return
- }
+ // To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator
+ if (reactionEvent.sender !== error.payload.sender) {
+ // Check if it's a room moderator
+ const {powers: {[reactionEvent.sender]: senderPower}, powerLevels} = await utils.getEffectivePower(roomID, [reactionEvent.sender], api)
+ if (senderPower < (powerLevels.state_default ?? 50)) return
+ }
- // Retry
- if (error.source === "matrix") {
- as.emit(`type:${error.payload.type}`, error.payload)
- } else if (error.source === "discord") {
- discord.cloud.emit("event", error.payload)
- }
+ // Retry
+ if (error.source === "matrix") {
+ as.emit(`type:${error.payload.type}`, error.payload)
+ } else if (error.source === "discord") {
+ discord.cloud.emit("event", error.payload)
+ }
- // Redact the error to stop people from executing multiple retries
- await api.redactEvent(roomID, event.event_id)
+ // Redact the error to stop people from executing multiple retries
+ await api.redactEvent(roomID, event.event_id)
+ }, roomID)
}
sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
@@ -194,6 +211,7 @@ async event => {
// @ts-ignore
await matrixCommandHandler.execute(event)
}
+ retrigger.messageFinishedBridging(event.event_id)
await api.ackEvent(event)
}))
@@ -203,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)
}))
@@ -296,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",
/**
@@ -312,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) {}
}
}))
@@ -323,47 +393,58 @@ sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
async event => {
if (event.state_key[0] !== "@") return
- if (event.content.membership === "invite" && event.state_key === `@${reg.sender_localpart}:${reg.ooye.server_name}`) {
- // We were invited to a room. We should join, and register the invite details for future reference in web.
- 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!")
- 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
- const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
- 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
)
}))
@@ -373,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} event
+ */
+async event => {
+ if (event.state_key !== "") return
+ if (!event.content.replacement_room) return
+ await roomUpgrade.onTombstone(event, api)
}))
module.exports.stringifyErrorStack = stringifyErrorStack
module.exports.sendError = sendError
+module.exports.printError = printError
diff --git a/src/matrix/api.js b/src/matrix/api.js
index edffc456..87bbf0cc 100644
--- a/src/matrix/api.js
+++ b/src/matrix/api.js
@@ -5,7 +5,7 @@ const assert = require("assert").strict
const streamWeb = require("stream/web")
const passthrough = require("../passthrough")
-const {sync} = passthrough
+const {sync, db, select} = passthrough
/** @type {import("./mreq")} */
const mreq = sync.require("./mreq")
/** @type {import("./txnid")} */
@@ -44,6 +44,7 @@ async function register(username) {
try {
await mreq.mreq("POST", "/client/v3/register", {
type: "m.login.application_service",
+ inhibit_login: true, // https://github.com/element-hq/matrix-bot-sdk/pull/70/changes https://github.com/matrix-org/matrix-spec-proposals/blob/quenting/as-device-management/proposals/4190-as-device-management.md
username
})
} catch (e) {
@@ -78,9 +79,17 @@ async function joinRoom(roomIDOrAlias, mxid, via) {
}
async function inviteToRoom(roomID, mxidToInvite, mxid) {
- await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/invite`, mxid), {
- user_id: mxidToInvite
- })
+ try {
+ await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/invite`, mxid), {
+ user_id: mxidToInvite
+ })
+ } catch (e) {
+ if (e.message.includes("is already in the room.") || e.message.includes("cannot invite user that is joined")) {
+ // Sweet!
+ } else {
+ throw e
+ }
+ }
}
async function leaveRoom(roomID, mxid) {
@@ -121,7 +130,20 @@ async function getEventForTimestamp(roomID, ts) {
/**
* @param {string} roomID
- * @returns {Promise}
+ * @param {"b" | "f"} dir
+ * @param {{from?: string, limit?: any}} [pagination]
+ * @param {any} [filter]
+ */
+async function getEvents(roomID, dir, pagination = {}, filter) {
+ filter = filter && JSON.stringify(filter)
+ /** @type {Ty.MessagesPagination>} */
+ const root = await mreq.mreq("GET", path(`/client/v3/rooms/${roomID}/messages`, null, {...pagination, dir, filter}))
+ return root
+}
+
+/**
+ * @param {string} roomID
+ * @returns {Promise[]>}
*/
function getAllState(roomID) {
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state`)
@@ -137,6 +159,97 @@ function getStateEvent(roomID, type, key) {
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}`)
}
+/**
+ * @param {string} roomID
+ * @param {string} type
+ * @param {string} key
+ * @returns {Promise>} the entire state event
+ */
+function getStateEventOuter(roomID, type, key) {
+ return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}?format=event`)
+}
+
+/**
+ * @param {string} roomID
+ * @param {{unsigned?: {invite_room_state?: Ty.Event.InviteStrippedState[]}}} [event]
+ * @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?}>}
+ */
+async function getInviteState(roomID, event) {
+ function getFromInviteRoomState(strippedState, nskey, key) {
+ if (!Array.isArray(strippedState)) return null
+ for (const event of strippedState) {
+ if (event.type === nskey && event.state_key === "") {
+ return event.content[key]
+ }
+ }
+ return null
+ }
+
+ // Try extracting from event (if passed)
+ if (Array.isArray(event?.unsigned?.invite_room_state) && event.unsigned.invite_room_state.length) {
+ return {
+ name: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.name", "name"),
+ topic: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.topic", "topic"),
+ avatar: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.avatar", "url"),
+ type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type")
+ }
+ }
+
+ // Try calling sliding sync API and extracting from stripped state
+ let root
+ try {
+ /** @type {Ty.R.SSS} */
+ root = await mreq.mreq("POST", path("/client/unstable/org.matrix.simplified_msc3575/sync", `@${reg.sender_localpart}:${reg.ooye.server_name}`, {timeout: "0"}), {
+ lists: {
+ a: {
+ ranges: [[0, 999]],
+ timeline_limit: 0,
+ required_state: [],
+ filters: {
+ is_invite: true
+ }
+ }
+ }
+ })
+
+ // Extract from sliding sync response if valid (seems to be okay on Synapse, Tuwunel and Continuwuity at time of writing)
+ if ("lists" in root) {
+ if (!root.rooms?.[roomID]) {
+ const e = new Error("Room data unavailable via SSS")
+ e["data_sss"] = root
+ throw e
+ }
+
+ const roomResponse = root.rooms[roomID]
+ const strippedState = "stripped_state" in roomResponse ? roomResponse.stripped_state : roomResponse.invite_state
+
+ return {
+ name: getFromInviteRoomState(strippedState, "m.room.name", "name"),
+ topic: getFromInviteRoomState(strippedState, "m.room.topic", "topic"),
+ avatar: getFromInviteRoomState(strippedState, "m.room.avatar", "url"),
+ type: getFromInviteRoomState(strippedState, "m.room.create", "type")
+ }
+ }
+ } catch (e) {}
+
+ // Invalid sliding sync response, try alternative (required for Conduit at time of writing)
+ const hierarchy = await getHierarchy(roomID, {limit: 1})
+ if (hierarchy?.rooms?.[0]?.room_id === roomID) {
+ const room = hierarchy?.rooms?.[0]
+ return {
+ name: room.name ?? null,
+ topic: room.topic ?? null,
+ avatar: room.avatar_url ?? null,
+ type: room.room_type ?? null
+ }
+ }
+
+ const e = new Error("Room data unavailable via SSS/hierarchy")
+ e["data_sss"] = root
+ e["data_hierarchy"] = hierarchy
+ throw e
+}
+
/**
* "Any of the AS's users must be in the room. This API is primarily for Application Services and should be faster to respond than /members as it can be implemented more efficiently on the server."
* @param {string} roomID
@@ -299,64 +412,33 @@ async function sendTyping(roomID, isTyping, mxid, duration) {
})
}
-async function profileSetDisplayname(mxid, displayname) {
- await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid), {
+/**
+ * @param {string} mxid
+ * @param {string} displayname
+ * @param {boolean} [inhibitPropagate]
+ */
+async function profileSetDisplayname(mxid, displayname, inhibitPropagate) {
+ const params = {}
+ if (inhibitPropagate) params["org.matrix.msc4069.propagate"] = false
+ await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid, params), {
displayname
})
}
-async function profileSetAvatarUrl(mxid, avatar_url) {
- await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid), {
- avatar_url
- })
-}
-
/**
- * Set a user's power level within a room.
- * @param {string} roomID
* @param {string} mxid
- * @param {number} newPower
+ * @param {string | null | undefined} avatar_url
+ * @param {boolean} [inhibitPropagate]
*/
-async function setUserPower(roomID, mxid, newPower) {
- assert(roomID[0] === "!")
- assert(mxid[0] === "@")
- // Yes there's no shortcut https://github.com/matrix-org/matrix-appservice-bridge/blob/2334b0bae28a285a767fe7244dad59f5a5963037/src/components/intent.ts#L352
- const power = await getStateEvent(roomID, "m.room.power_levels", "")
- power.users = power.users || {}
-
- // Check if it has really changed to avoid sending a useless state event
- // (Can't diff kstate here because of (a) circular imports (b) kstate has special behaviour diffing power levels)
- const oldPowerLevel = power.users?.[mxid] ?? power.users_default ?? 0
- if (oldPowerLevel === newPower) return
-
- // Bridge bot can't demote equal power users, so need to decide which user will send the event
- const botPowerLevel = power.users?.[`@${reg.sender_localpart}:${reg.ooye.server_name}`] ?? power.users_default ?? 0
- const eventSender = oldPowerLevel >= botPowerLevel ? mxid : undefined
-
- // Update the event content
- if (newPower == null || newPower === (power.users_default ?? 0)) {
- delete power.users[mxid]
+async function profileSetAvatarUrl(mxid, avatar_url, inhibitPropagate) {
+ const params = {}
+ if (inhibitPropagate) params["org.matrix.msc4069.propagate"] = false
+ if (avatar_url) {
+ await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid, params), {
+ avatar_url
+ })
} else {
- power.users[mxid] = newPower
- }
-
- await sendState(roomID, "m.room.power_levels", "", power, eventSender)
- return power
-}
-
-/**
- * Set a user's power level for a whole room hierarchy.
- * @param {string} spaceID
- * @param {string} mxid
- * @param {number} power
- */
-async function setUserPowerCascade(spaceID, mxid, power) {
- assert(spaceID[0] === "!")
- assert(mxid[0] === "@")
- const rooms = await getFullHierarchy(spaceID)
- await setUserPower(spaceID, mxid, power)
- for (const room of rooms) {
- await setUserPower(room.room_id, mxid, power)
+ await mreq.mreq("DELETE", path(`/client/v3/profile/${mxid}/avatar_url`, mxid, params))
}
}
@@ -378,19 +460,26 @@ async function ping() {
}
/**
+ * Given an mxc:// URL, and an optional height for thumbnailing, get the file from the content repository. Returns res.
* @param {string} mxc
- * @param {RequestInit} [init]
+ * @param {RequestInit & {height?: number | string}} [init]
* @return {Promise}>}
*/
async function getMedia(mxc, init = {}) {
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
assert(mediaParts)
- const res = await fetch(`${mreq.baseUrl}/client/v1/media/download/${mediaParts[1]}/${mediaParts[2]}`, {
+ const downloadOrThumbnail = init.height ? "thumbnail" : "download"
+ let url = `${mreq.baseUrl}/client/v1/media/${downloadOrThumbnail}/${mediaParts[1]}/${mediaParts[2]}`
+ if (init.height) url += "?" + new URLSearchParams({height: String(init.height), width: String(init.height)})
+ const res = await fetch(url, {
headers: {
Authorization: `Bearer ${reg.as_token}`
},
...init
})
+ if (res.status !== 200) {
+ throw await mreq.makeMatrixServerError(res, {...init, url})
+ }
if (init.method !== "HEAD") {
assert(res.body)
}
@@ -465,6 +554,40 @@ function getProfile(mxid) {
return mreq.mreq("GET", `/client/v3/profile/${mxid}`)
}
+function versions() {
+ return mreq.mreq("GET", "/client/versions")
+}
+
+/**
+ * @param {string} mxid
+ */
+async function usePrivateChat(mxid) {
+ // Check if we have an existing DM
+ let roomID = select("direct", "room_id", {mxid}).pluck().get()
+ if (roomID) {
+ // Check that the person is/still in the room
+ try {
+ var member = await getStateEvent(roomID, "m.room.member", mxid)
+ } catch (e) {}
+
+ // Invite them back to the room if needed
+ if (!member || member.membership === "leave") {
+ await inviteToRoom(roomID, mxid)
+ }
+ return roomID
+ }
+
+ // No existing DM, create a new room and invite
+ roomID = await createRoom({
+ invite: [mxid],
+ is_direct: true,
+ preset: "trusted_private_chat"
+ })
+ // Store the newly created room in the database (not using account data due to awkward bugs with misaligned state)
+ db.prepare("REPLACE INTO direct (mxid, room_id) VALUES (?, ?)").run(mxid, roomID)
+ return roomID
+}
+
module.exports.path = path
module.exports.register = register
module.exports.createRoom = createRoom
@@ -474,8 +597,11 @@ module.exports.leaveRoom = leaveRoom
module.exports.leaveRoomWithReason = leaveRoomWithReason
module.exports.getEvent = getEvent
module.exports.getEventForTimestamp = getEventForTimestamp
+module.exports.getEvents = getEvents
module.exports.getAllState = getAllState
module.exports.getStateEvent = getStateEvent
+module.exports.getStateEventOuter = getStateEventOuter
+module.exports.getInviteState = getInviteState
module.exports.getJoinedMembers = getJoinedMembers
module.exports.getMembers = getMembers
module.exports.getHierarchy = getHierarchy
@@ -489,8 +615,6 @@ module.exports.redactEvent = redactEvent
module.exports.sendTyping = sendTyping
module.exports.profileSetDisplayname = profileSetDisplayname
module.exports.profileSetAvatarUrl = profileSetAvatarUrl
-module.exports.setUserPower = setUserPower
-module.exports.setUserPowerCascade = setUserPowerCascade
module.exports.ping = ping
module.exports.getMedia = getMedia
module.exports.sendReadReceipt = sendReadReceipt
@@ -500,3 +624,5 @@ module.exports.getAccountData = getAccountData
module.exports.setAccountData = setAccountData
module.exports.setPresence = setPresence
module.exports.getProfile = getProfile
+module.exports.versions = versions
+module.exports.usePrivateChat = usePrivateChat
diff --git a/src/matrix/appservice.js b/src/matrix/appservice.js
index 67f16ee1..8f85a514 100644
--- a/src/matrix/appservice.js
+++ b/src/matrix/appservice.js
@@ -3,6 +3,5 @@
const {reg} = require("../matrix/read-registration")
const {AppService} = require("@cloudrac3r/in-your-element")
const as = new AppService(reg)
-as.listen()
module.exports.as = as
diff --git a/src/matrix/file.js b/src/matrix/file.js
index 2070a568..7bc1fec5 100644
--- a/src/matrix/file.js
+++ b/src/matrix/file.js
@@ -103,9 +103,9 @@ function memberAvatar(guildID, user, member) {
}
function emoji(emojiID, animated) {
- const base = `/emojis/${emojiID}`
- if (animated) return base + ".gif"
- else return base + ".png"
+ const base = `/emojis/${emojiID}.webp`
+ if (animated) return base + "?animated=true"
+ else return base
}
const stickerFormat = new Map([
diff --git a/src/matrix/kstate.js b/src/matrix/kstate.js
index 03d09e06..3648f2d7 100644
--- a/src/matrix/kstate.js
+++ b/src/matrix/kstate.js
@@ -10,6 +10,8 @@ const {sync} = passthrough
const file = sync.require("./file")
/** @type {import("./api")} */
const api = sync.require("./api")
+/** @type {import("./utils")} */
+const utils = sync.require("./utils")
/** Mutates the input. Not recursive - can only include or exclude entire state events. */
function kstateStripConditionals(kstate) {
@@ -45,7 +47,7 @@ async function kstateUploadMxc(obj) {
return obj
}
-/** Automatically strips conditionals and uploads URLs to mxc. */
+/** Automatically strips conditionals and uploads URLs to mxc. m.room.create is removed. */
async function kstateToState(kstate) {
const events = []
kstateStripConditionals(kstate)
@@ -55,19 +57,30 @@ async function kstateToState(kstate) {
assert(slashIndex > 0)
const type = k.slice(0, slashIndex)
const state_key = k.slice(slashIndex + 1)
+ if (type === "m.room.create") continue
events.push({type, state_key, content})
}
return events
}
+/** Extracts m.room.create for use in room creation_content. */
+function kstateToCreationContent(kstate) {
+ return kstate["m.room.create/"] || {}
+}
+
/**
- * @param {import("../types").Event.BaseStateEvent[]} events
+ * @param {import("../types").Event.StateOuter[]} events
* @returns {any}
*/
function stateToKState(events) {
const kstate = {}
for (const event of events) {
kstate[event.type + "/" + event.state_key] = event.content
+
+ // need to remember m.room.create sender for later...
+ if (event.type === "m.room.create" && event.state_key === "") {
+ kstate["m.room.create/outer"] = event
+ }
}
return kstate
}
@@ -81,15 +94,27 @@ function diffKState(actual, target) {
if (key === "m.room.power_levels/") {
// Special handling for power levels, we want to deep merge the actual and target into the final state.
if (!(key in actual)) throw new Error(`want to apply a power levels diff, but original power level data is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`)
- const temp = mixin({}, actual[key], target[key])
- if (!isDeepStrictEqual(actual[key], temp)) {
+ // if the diff includes users, it needs to be cleaned wrt room version 12
+ const cleanedTarget = mixin({}, target[key])
+ if (target[key].users && Object.keys(target[key].users).length > 0) {
+ assert("m.room.create/" in actual, `want to apply a power levels diff, but original m.room.create/ is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`)
+ assert("m.room.create/outer" in actual, `want to apply a power levels diff, but original m.room.create/outer is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`)
+ utils.removeCreatorsFromPowerLevels(actual["m.room.create/outer"], cleanedTarget)
+ }
+ const mixedTarget = mixin({}, actual[key], cleanedTarget)
+ if (!isDeepStrictEqual(actual[key], mixedTarget)) {
// they differ. use the newly prepared object as the diff.
- diff[key] = temp
+ diff[key] = mixedTarget
}
- } else if (key === "chat.schildi.hide_ui/read_receipts") {
- // Special handling: don't add this key if it's new. Do overwrite if already present.
- if (key in actual) {
+ } else if (key === "m.room.create/") {
+ // can't be modified - only for kstateToCreationContent
+
+ } else if (key === "m.room.topic/") {
+ // synapse generates different m.room.topic events on original creation
+ // https://github.com/element-hq/synapse/blob/0f2b29511fd88d1dc2278f41fd6e4e2f2989fcb7/synapse/handlers/room.py#L1729
+ // diff the `topic` to determine change
+ if (!(key in actual) || actual[key].topic !== target[key].topic) {
diff[key] = target[key]
}
@@ -115,10 +140,22 @@ function diffKState(actual, target) {
/**
* Async because it gets all room state from the homeserver.
* @param {string} roomID
+ * @param {[type: string, key: string][]} [limitToEvents]
*/
-async function roomToKState(roomID) {
- const root = await api.getAllState(roomID)
- return stateToKState(root)
+async function roomToKState(roomID, limitToEvents) {
+ if (!limitToEvents) {
+ const root = await api.getAllState(roomID)
+ return stateToKState(root)
+ } else {
+ const root = []
+ await Promise.all(limitToEvents.map(async ([type, key]) => {
+ try {
+ const outer = await api.getStateEventOuter(roomID, type, key)
+ root.push(outer)
+ } catch (e) {}
+ }))
+ return stateToKState(root)
+ }
}
/**
@@ -135,6 +172,7 @@ async function applyKStateDiffToRoom(roomID, kstate) {
module.exports.kstateStripConditionals = kstateStripConditionals
module.exports.kstateUploadMxc = kstateUploadMxc
module.exports.kstateToState = kstateToState
+module.exports.kstateToCreationContent = kstateToCreationContent
module.exports.stateToKState = stateToKState
module.exports.diffKState = diffKState
module.exports.roomToKState = roomToKState
diff --git a/src/matrix/kstate.test.js b/src/matrix/kstate.test.js
index 1b67ad51..b67a7252 100644
--- a/src/matrix/kstate.test.js
+++ b/src/matrix/kstate.test.js
@@ -1,5 +1,5 @@
const assert = require("assert")
-const {kstateToState, stateToKState, diffKState, kstateStripConditionals, kstateUploadMxc} = require("./kstate")
+const {kstateToState, stateToKState, diffKState, kstateStripConditionals, kstateUploadMxc, kstateToCreationContent} = require("./kstate")
const {test} = require("supertape")
test("kstate strip: strips false conditions", t => {
@@ -68,6 +68,8 @@ test("kstateUploadMxc and strip: work together", async t => {
test("kstate2state: general", async t => {
t.deepEqual(await kstateToState({
+ "m.room.create/": {bogus: true},
+ "m.room.create/outer": {bogus: true},
"m.room.name/": {name: "test name"},
"m.room.member/@cadence:cadence.moe": {membership: "join"},
"uk.half-shot.bridge/org.matrix.appservice-irc://irc/epicord.net/#general": {creator: "@cadence:cadence.moe"}
@@ -98,6 +100,14 @@ test("kstate2state: general", async t => {
test("state2kstate: general", t => {
t.deepEqual(stateToKState([
+ {
+ type: "m.room.create",
+ state_key: "",
+ sender: "@example:matrix.org",
+ content: {
+ room_version: "12"
+ }
+ },
{
type: "m.room.name",
state_key: "",
@@ -122,7 +132,9 @@ test("state2kstate: general", t => {
]), {
"m.room.name/": {name: "test name"},
"m.room.member/@cadence:cadence.moe": {membership: "join"},
- "uk.half-shot.bridge/org.matrix.appservice-irc://irc/epicord.net/#general": {creator: "@cadence:cadence.moe"}
+ "uk.half-shot.bridge/org.matrix.appservice-irc://irc/epicord.net/#general": {creator: "@cadence:cadence.moe"},
+ "m.room.create/": {room_version: "12"},
+ "m.room.create/outer": {type: "m.room.create", state_key: "", sender: "@example:matrix.org", content: {room_version: "12"}}
})
})
@@ -157,6 +169,17 @@ test("diffKState: detects new properties", t => {
test("diffKState: power levels are mixed together", t => {
const original = {
+ "m.room.create/outer": {
+ type: "m.room.create",
+ state_key: "",
+ sender: "@example:matrix.org",
+ content: {
+ room_version: "11"
+ }
+ },
+ "m.room.create/": {
+ room_version: "11"
+ },
"m.room.power_levels/": {
"ban": 50,
"events": {
@@ -181,6 +204,9 @@ test("diffKState: power levels are mixed together", t => {
"m.room.power_levels/": {
"events": {
"m.room.avatar": 0
+ },
+ users: {
+ "@example:matrix.org": 100
}
}
})
@@ -201,7 +227,8 @@ test("diffKState: power levels are mixed together", t => {
"redact": 50,
"state_default": 50,
"users": {
- "@example:localhost": 100
+ "@example:localhost": 100,
+ "@example:matrix.org": 100
},
"users_default": 0
}
@@ -235,30 +262,189 @@ test("diffKState: kstate keys must contain a slash separator", t => {
t.pass()
})
-test("diffKState: don't add hide_ui when not present", t => {
- test("diffKState: detects new properties", t => {
- t.deepEqual(
- diffKState({
- }, {
- "chat.schildi.hide_ui/read_receipts/": {}
- }),
- {
+test("diffKState: topic does not change if the topic key has not changed", t => {
+ t.deepEqual(diffKState({
+ "m.room.topic/": {
+ topic: "hello",
+ "m.topic": {
+ "m.text": "hello"
}
- )
+ }
+ }, {
+ "m.room.topic/": {
+ topic: "hello"
+ }
+ }),
+ {})
+})
+
+test("diffKState: topic changes if the topic key has changed", t => {
+ t.deepEqual(diffKState({
+ "m.room.topic/": {
+ topic: "hello",
+ "m.topic": {
+ "m.text": "hello"
+ }
+ }
+ }, {
+ "m.room.topic/": {
+ topic: "hello you"
+ }
+ }),
+ {
+ "m.room.topic/": {
+ topic: "hello you"
+ }
})
})
-test("diffKState: overwriten hide_ui when present", t => {
- test("diffKState: detects new properties", t => {
- t.deepEqual(
- diffKState({
- "chat.schildi.hide_ui/read_receipts/": {hidden: true}
- }, {
- "chat.schildi.hide_ui/read_receipts/": {}
- }),
- {
- "chat.schildi.hide_ui/read_receipts/": {}
+test("diffKState: room v12 creators cannot be introduced into power levels", t => {
+ const original = {
+ "m.room.create/outer": {
+ type: "m.room.create",
+ state_key: "",
+ sender: "@example1:matrix.org",
+ content: {
+ additional_creators: ["@example2:matrix.org"],
+ room_version: "12"
}
- )
+ },
+ "m.room.create/": {
+ room_version: "12"
+ },
+ "m.room.power_levels/": {
+ "ban": 50,
+ "events": {
+ "m.room.name": 100,
+ "m.room.power_levels": 100
+ },
+ "events_default": 0,
+ "invite": 50,
+ "kick": 50,
+ "notifications": {
+ "room": 20
+ },
+ "redact": 50,
+ "state_default": 50,
+ "users": {
+ "@example:localhost": 100
+ },
+ "users_default": 0
+ }
+ }
+ const result = diffKState(original, {
+ "m.room.create/": {
+ bogus: true
+ },
+ "m.room.power_levels/": {
+ events: {
+ "m.room.avatar": 0
+ },
+ users: {
+ "@example1:matrix.org": 100,
+ "@example2:matrix.org": 100,
+ "@example3:matrix.org": 100
+ }
+ }
+ })
+ t.deepEqual(result, {
+ "m.room.power_levels/": {
+ "ban": 50,
+ "events": {
+ "m.room.name": 100,
+ "m.room.power_levels": 100,
+ "m.room.avatar": 0
+ },
+ "events_default": 0,
+ "invite": 50,
+ "kick": 50,
+ "notifications": {
+ "room": 20
+ },
+ "redact": 50,
+ "state_default": 50,
+ "users": {
+ "@example:localhost": 100,
+ "@example3:matrix.org": 100
+ },
+ "users_default": 0
+ }
+ })
+ t.notDeepEqual(original, result)
+})
+
+test("diffKState: room v12 creators cannot be introduced into power levels - no diff if no changes", t => {
+ const original = {
+ "m.room.create/outer": {
+ type: "m.room.create",
+ state_key: "",
+ sender: "@example1:matrix.org",
+ content: {
+ additional_creators: ["@example2:matrix.org"],
+ room_version: "12"
+ }
+ },
+ "m.room.create/": {
+ additional_creators: ["@example2:matrix.org"],
+ room_version: "12"
+ },
+ "m.room.power_levels/": {
+ "ban": 50,
+ "events": {
+ "m.room.name": 100,
+ "m.room.power_levels": 100
+ },
+ "events_default": 0,
+ "invite": 50,
+ "kick": 50,
+ "notifications": {
+ "room": 20
+ },
+ "redact": 50,
+ "state_default": 50,
+ "users": {
+ "@example:localhost": 100
+ },
+ "users_default": 0
+ }
+ }
+ const result = diffKState(original, {
+ "m.room.power_levels/": {
+ users: {
+ "@example1:matrix.org": 100,
+ "@example2:matrix.org": 100
+ }
+ }
+ })
+ t.deepEqual(result, {})
+ t.notDeepEqual(original, result)
+})
+
+test("kstateToCreationContent: works", t => {
+ const original = {
+ "m.room.create/outer": {
+ type: "m.room.create",
+ state_key: "",
+ sender: "@example1:matrix.org",
+ content: {
+ additional_creators: ["@example2:matrix.org"],
+ room_version: "12",
+ type: "m.space"
+ }
+ },
+ "m.room.create/": {
+ additional_creators: ["@example2:matrix.org"],
+ room_version: "12",
+ type: "m.space"
+ }
+ }
+ t.deepEqual(kstateToCreationContent(original), {
+ additional_creators: ["@example2:matrix.org"],
+ room_version: "12",
+ type: "m.space"
})
})
+
+test("kstateToCreationContent: works if empty", t => {
+ t.deepEqual(kstateToCreationContent({}), {})
+})
diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js
index 93bc3121..e382a329 100644
--- a/src/matrix/matrix-command-handler.js
+++ b/src/matrix/matrix-command-handler.js
@@ -8,8 +8,8 @@ const sharp = require("sharp")
const {discord, sync, db, select} = require("../passthrough")
/** @type {import("./api")}) */
const api = sync.require("./api")
-/** @type {import("../m2d/converters/utils")} */
-const mxUtils = sync.require("../m2d/converters/utils")
+/** @type {import("./utils")} */
+const mxUtils = sync.require("./utils")
/** @type {import("../discord/utils")} */
const dUtils = sync.require("../discord/utils")
/** @type {import("./kstate")} */
@@ -58,7 +58,7 @@ async function addButton(roomID, eventID, key, mxid) {
setInterval(() => {
const now = Date.now()
buttons = buttons.filter(b => now - b.created < 2*60*60*1000)
-}, 10*60*1000)
+}, 10*60*1000).unref()
/** @param {Ty.Event.Outer} event */
function onReactionAdd(event) {
@@ -114,7 +114,7 @@ const commands = [{
const guild = discord.guilds.get(guildID)
assert(guild)
const slots = getSlotCount(guild.premium_tier)
- const permissions = dUtils.getPermissions([], guild.roles)
+ const permissions = dUtils.getPermissions(guild.id, [], guild.roles)
if (guild.emojis.length >= slots) {
matrixOnlyReason = "CAPACITY"
} else if (!(permissions & 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...)
@@ -123,12 +123,9 @@ const commands = [{
}
if (matrixOnlyReason) {
// If uploading to Matrix, check if we have permission
- const state = await api.getAllState(event.room_id)
- const kstate = ks.stateToKState(state)
- const powerLevels = kstate["m.room.power_levels/"]
- const required = powerLevels.events["im.ponies.room_emotes"] ?? powerLevels.state_default ?? 50
- const have = powerLevels.users[`@${reg.sender_localpart}:${reg.ooye.server_name}`] ?? powerLevels.users_default ?? 0
- if (have < required) {
+ const {powerLevels, powers: {[mxUtils.bot]: botPower}} = await mxUtils.getEffectivePower(event.room_id, [mxUtils.bot], api)
+ const requiredPower = powerLevels.events?.["im.ponies.room_emotes"] ?? powerLevels.state_default ?? 50
+ if (botPower < requiredPower) {
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
msgtype: "m.text",
@@ -177,7 +174,7 @@ const commands = [{
.addLine(`Ⓜ️ *If you were a Discord user, you wouldn't have permission to create emojis. ${matrixOnlyConclusion}`, `Ⓜ️ If you were a Discord user, you wouldn't have permission to create emojis. ${matrixOnlyConclusion}`, matrixOnlyReason === "CAPACITY")
.addLine("[Preview not available in plain text.]", "Preview:")
for (const e of toUpload) {
- b.add("", `
`)
+ b.add("", `:${e.name}:
`)
}
b.addLine("Hit ✅ to add it.")
const sent = await api.sendEvent(event.room_id, "m.room.message", {
@@ -253,7 +250,7 @@ const commands = [{
const guild = discord.guilds.get(guildID)
assert(guild)
- const permissions = dUtils.getPermissions([], guild.roles)
+ const permissions = dUtils.getPermissions(guild.id, [], guild.roles)
if (!(permissions & 0x800000000n)) { // CREATE_PUBLIC_THREADS
return api.sendEvent(event.room_id, "m.room.message", {
...ctx,
diff --git a/src/matrix/mreq.js b/src/matrix/mreq.js
index 91798254..bb59506f 100644
--- a/src/matrix/mreq.js
+++ b/src/matrix/mreq.js
@@ -37,6 +37,21 @@ async function _convertBody(body) {
/* c8 ignore start */
+/**
+ * @param {Response} res
+ * @param {object} opts
+ */
+async function makeMatrixServerError(res, opts = {}) {
+ delete opts.headers?.["Authorization"]
+ if (res.headers.get("content-type") === "application/json") {
+ return new MatrixServerError(await res.json(), opts)
+ } else if (res.headers.get("content-type")?.startsWith("text/")) {
+ return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, message: await res.text()}, opts)
+ } else {
+ return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, content_type: res.headers.get("content-type")}, opts)
+ }
+}
+
/**
* @param {string} method
* @param {string} url
@@ -57,7 +72,14 @@ async function mreq(method, url, bodyIn, extra = {}) {
}, extra)
const res = await fetch(baseUrl + url, opts)
- const root = await res.json()
+ const text = await res.text()
+ try {
+ /** @type {any} */
+ var root = JSON.parse(text)
+ } catch (e) {
+ delete opts.headers?.["Authorization"]
+ throw new MatrixServerError(text, {baseUrl, url, ...opts})
+ }
if (!res.ok || root.errcode) {
delete opts.headers?.["Authorization"]
@@ -85,6 +107,7 @@ async function withAccessToken(token, callback) {
}
module.exports.MatrixServerError = MatrixServerError
+module.exports.makeMatrixServerError = makeMatrixServerError
module.exports.baseUrl = baseUrl
module.exports.mreq = mreq
module.exports.withAccessToken = withAccessToken
diff --git a/src/matrix/read-registration.js b/src/matrix/read-registration.js
index d126851c..114bf756 100644
--- a/src/matrix/read-registration.js
+++ b/src/matrix/read-registration.js
@@ -11,7 +11,7 @@ const registrationFilePath = path.join(process.cwd(), "registration.yaml")
function checkRegistration(reg) {
reg["ooye"].invite = reg.ooye.invite.filter(mxid => mxid.endsWith(`:${reg.ooye.server_name}`)) // one day I will understand why typescript disagrees with dot notation on this line
assert(reg.ooye?.max_file_size)
- assert(reg.ooye?.namespace_prefix)
+ assert(reg.ooye?.namespace_prefix != null)
assert(reg.ooye?.server_name)
assert(reg.sender_localpart?.startsWith(reg.ooye.namespace_prefix), "appservice's localpart must be in the namespace it controls")
assert(reg.ooye?.server_origin.match(/^https?:\/\//), "server origin must start with http or https")
@@ -22,7 +22,7 @@ function checkRegistration(reg) {
/* c8 ignore next 4 */
/** @param {import("../types").AppServiceRegistrationConfig} reg */
function writeRegistration(reg) {
- fs.writeFileSync(registrationFilePath, JSON.stringify(reg, null, 2))
+ fs.writeFileSync(registrationFilePath, JSON.stringify(reg, null, 2) + "\n")
}
/**
@@ -57,7 +57,8 @@ function getTemplateRegistration(serverName) {
max_file_size: 5000000,
content_length_workaround: false,
include_user_id_in_mxid: false,
- invite: []
+ invite: [],
+ receive_presences: true
}
}
}
diff --git a/src/matrix/room-upgrade.js b/src/matrix/room-upgrade.js
new file mode 100644
index 00000000..5a2606ee
--- /dev/null
+++ b/src/matrix/room-upgrade.js
@@ -0,0 +1,96 @@
+// @ts-check
+
+const assert = require("assert/strict")
+const Ty = require("../types")
+const {Semaphore} = require("@chriscdn/promise-semaphore")
+const {tag} = require("@cloudrac3r/html-template-tag")
+const {db, sync, select, from} = require("../passthrough")
+
+/** @type {import("./utils")}) */
+const utils = sync.require("./utils")
+
+const roomUpgradeSema = new Semaphore()
+
+/**
+ * @param {Ty.Event.StateOuter} event
+ * @param {import("./api")} api
+ */
+async function onTombstone(event, api) {
+ // Preconditions (checked by event-dispatcher, enforced here)
+ assert.equal(event.state_key, "")
+ assert.ok(event.content.replacement_room)
+
+ // Set up
+ const oldRoomID = event.room_id
+ const newRoomID = event.content.replacement_room
+ const channel = select("channel_room", ["name", "channel_id"], {room_id: oldRoomID}).get()
+ if (!channel) return
+ db.prepare("REPLACE INTO room_upgrade_pending (new_room_id, old_room_id) VALUES (?, ?)").run(newRoomID, oldRoomID)
+
+ // Try joining
+ try {
+ await api.joinRoom(newRoomID)
+ } catch (e) {
+ const message = new utils.MatrixStringBuilder()
+ message.add(
+ `You upgraded the bridged room ${channel.name}. To keep bridging, I need you to invite me to the new room: https://matrix.to/#/${newRoomID}`,
+ tag`You upgraded the bridged room ${channel.name}. To keep bridging, I need you to invite me to the new room: https://matrix.to/#/${newRoomID}`
+ )
+ const privateRoomID = await api.usePrivateChat(event.sender)
+ await api.sendEvent(privateRoomID, "m.room.message", message.get())
+ }
+
+ // Now wait to be invited to/join the room that has the upgrade pending...
+}
+
+/**
+ * @param {Ty.Event.StateOuter} event
+ * @param {import("./api")} api
+ * @param {import("../d2m/actions/create-room")} createRoom
+ * @returns {Promise} whether to cancel other membership actions
+ */
+async function onBotMembership(event, api, createRoom) {
+ // Preconditions (checked by event-dispatcher, enforced here)
+ assert.equal(event.type, "m.room.member")
+ 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 () => {
+ // If invited, join
+ if (event.content.membership === "invite") {
+ await api.joinRoom(newRoomID)
+ }
+
+ // Remove old room from space
+ await api.sendState(channelRow.space_id, "m.space.child", oldRoomID, {})
+ // await api.sendState(oldRoomID, "m.space.parent", spaceID, {}) // keep this - the room isn't advertised but should still be grouped if opened
+
+ // Remove declaration that old room is bridged (if able)
+ try {
+ await api.sendState(oldRoomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${channelRow.guild_id}/${channelRow.channel_id}`, {})
+ } catch (e) { /* c8 ignore next */ }
+
+ // Update database
+ db.transaction(() => {
+ db.prepare("DELETE FROM room_upgrade_pending WHERE new_room_id = ?").run(newRoomID)
+ db.prepare("UPDATE channel_room SET room_id = ? WHERE channel_id = ?").run(newRoomID, channelRow.channel_id)
+ db.prepare("INSERT INTO historical_channel_room (room_id, reference_channel_id, upgraded_timestamp) VALUES (?, ?, ?)").run(newRoomID, channelRow.channel_id, Date.now())
+ })()
+
+ // Sync
+ await createRoom.syncRoom(channelRow.channel_id)
+ return true
+ }, event.room_id)
+}
+
+module.exports.onTombstone = onTombstone
+module.exports.onBotMembership = onBotMembership
diff --git a/src/matrix/room-upgrade.test.js b/src/matrix/room-upgrade.test.js
new file mode 100644
index 00000000..3de1a8f3
--- /dev/null
+++ b/src/matrix/room-upgrade.test.js
@@ -0,0 +1,169 @@
+const {test} = require("supertape")
+const {select} = require("../passthrough")
+const {onTombstone, onBotMembership} = require("./room-upgrade")
+
+test("join upgraded room: only cares about upgrades in progress", async t => {
+ let called = 0
+ await onBotMembership({
+ type: "m.room.member",
+ state_key: "@_ooye_bot:cadence.moe",
+ room_id: "!JBxeGYnzQwLnaooOLD:cadence.moe",
+ content: {
+ membership: "invite"
+ }
+ }, {
+ /* c8 ignore next 4 */
+ async joinRoom(roomID) {
+ called++
+ throw new Error("should not join this room")
+ }
+ })
+ t.equal(called, 0)
+})
+
+test("tombstone: only cares about bridged rooms", async t => {
+ let called = 0
+ await onTombstone({
+ event_id: "$tombstone",
+ type: "m.room.tombstone",
+ state_key: "",
+ sender: "@cadence:cadence.moe",
+ origin_server_ts: 0,
+ room_id: "!imaginary:cadence.moe",
+ content: {
+ body: "This room has been replaced",
+ replacement_room: "!JBxeGYnzQwLnaooNEW:cadence.moe"
+ }
+ }, {
+ /* c8 ignore next 4 */
+ async joinRoom(roomID) {
+ called++
+ throw new Error("should not join this room")
+ }
+ })
+ t.equal(called, 0)
+})
+
+test("tombstone: joins new room and stores upgrade in database", async t => {
+ let called = 0
+ await onTombstone({
+ event_id: "$tombstone",
+ type: "m.room.tombstone",
+ state_key: "",
+ sender: "@cadence:cadence.moe",
+ origin_server_ts: 0,
+ room_id: "!JBxeGYnzQwLnaooOLD:cadence.moe",
+ content: {
+ body: "This room has been replaced",
+ replacement_room: "!JBxeGYnzQwLnaooNEW:cadence.moe"
+ }
+ }, {
+ async joinRoom(roomID) {
+ called++
+ t.equal(roomID, "!JBxeGYnzQwLnaooNEW:cadence.moe")
+ return roomID
+ }
+ })
+ t.equal(called, 1)
+ t.ok(select("room_upgrade_pending", ["old_room_id", "new_room_id"], {new_room_id: "!JBxeGYnzQwLnaooNEW:cadence.moe", old_room_id: "!JBxeGYnzQwLnaooOLD:cadence.moe"}).get())
+})
+
+test("tombstone: requests invite from upgrader if can't join room", async t => {
+ let called = 0
+ await onTombstone({
+ event_id: "$tombstone",
+ type: "m.room.tombstone",
+ state_key: "",
+ sender: "@cadence:cadence.moe",
+ origin_server_ts: 0,
+ room_id: "!JBxeGYnzQwLnaooOLD:cadence.moe",
+ content: {
+ body: "This room has been replaced",
+ replacement_room: "!JBxeGYnzQwLnaooNEW:cadence.moe"
+ }
+ }, {
+ async joinRoom(roomID) {
+ called++
+ t.equal(roomID, "!JBxeGYnzQwLnaooNEW:cadence.moe")
+ throw new Error("access denied or something")
+ },
+ async usePrivateChat(sender) {
+ called++
+ t.equal(sender, "@cadence:cadence.moe")
+ return "!private"
+ },
+ async sendEvent(roomID, type, content) {
+ called++
+ t.equal(roomID, "!private")
+ t.equal(type, "m.room.message")
+ t.deepEqual(content, {
+ msgtype: "m.text",
+ body: "You upgraded the bridged room winners. To keep bridging, I need you to invite me to the new room: https://matrix.to/#/!JBxeGYnzQwLnaooNEW:cadence.moe",
+ format: "org.matrix.custom.html",
+ formatted_body: `You upgraded the bridged room winners. To keep bridging, I need you to invite me to the new room: https://matrix.to/#/!JBxeGYnzQwLnaooNEW:cadence.moe`
+ })
+ }
+ })
+ t.equal(called, 3)
+})
+
+test("join upgraded room: only cares about invites/joins", async t => {
+ let called = 0
+ await onBotMembership({
+ type: "m.room.member",
+ state_key: "@_ooye_bot:cadence.moe",
+ room_id: "!JBxeGYnzQwLnaooNEW:cadence.moe",
+ content: {
+ membership: "leave"
+ }
+ }, {
+ /* c8 ignore next 4 */
+ async joinRoom(roomID) {
+ called++
+ throw new Error("should not join this room")
+ }
+ })
+ t.equal(called, 0)
+})
+
+test("join upgraded room: joins invited room, updates database", async t => {
+ let called = 0
+ await onBotMembership({
+ type: "m.room.member",
+ state_key: "@_ooye_bot:cadence.moe",
+ room_id: "!JBxeGYnzQwLnaooNEW:cadence.moe",
+ content: {
+ membership: "invite"
+ }
+ }, {
+ async joinRoom(roomID) {
+ called++
+ t.equal(roomID, "!JBxeGYnzQwLnaooNEW:cadence.moe")
+ return roomID
+ },
+ async sendState(roomID, type, key, content) {
+ called++
+ if (type === "m.space.child") {
+ t.equal(roomID, "!CvQMeeqXIkgedUpkzv:cadence.moe") // space
+ t.equal(key, "!JBxeGYnzQwLnaooOLD:cadence.moe")
+ t.deepEqual(content, {})
+ return "$child"
+ } else if (type === "uk.half-shot.bridge") {
+ t.equal(roomID, "!JBxeGYnzQwLnaooOLD:cadence.moe")
+ t.equal(key, "moe.cadence.ooye://discord/1345641201902288987/598707048112193536")
+ t.deepEqual(content, {})
+ return "$bridge"
+ }
+ /* c8 ignore next */
+ throw new Error(`unexpected sendState: ${roomID} - ${type}/${key}`)
+ }
+ }, {
+ async syncRoom(channelID) {
+ called++
+ t.equal(channelID, "598707048112193536")
+ }
+ })
+ t.equal(called, 4)
+ t.equal(select("channel_room", "room_id", {channel_id: "598707048112193536"}).pluck().get(), "!JBxeGYnzQwLnaooNEW:cadence.moe")
+ t.equal(select("historical_channel_room", "historical_room_index", {reference_channel_id: "598707048112193536"}).pluck().all().length, 2)
+})
diff --git a/src/matrix/utils.js b/src/matrix/utils.js
new file mode 100644
index 00000000..9f5cb0f6
--- /dev/null
+++ b/src/matrix/utils.js
@@ -0,0 +1,415 @@
+// @ts-check
+
+const assert = require("assert").strict
+const Ty = require("../types")
+const {tag} = require("@cloudrac3r/html-template-tag")
+const passthrough = require("../passthrough")
+const {db} = passthrough
+
+const {reg} = require("./read-registration")
+const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex))
+
+/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
+let hasher = null
+// @ts-ignore
+require("xxhash-wasm")().then(h => hasher = h)
+
+const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
+
+const BLOCK_ELEMENTS = [
+ "ADDRESS", "ARTICLE", "ASIDE", "AUDIO", "BLOCKQUOTE", "BODY", "CANVAS",
+ "CENTER", "DD", "DETAILS", "DIR", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE",
+ "FOOTER", "FORM", "FRAMESET", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER",
+ "HGROUP", "HR", "HTML", "ISINDEX", "LI", "MAIN", "MENU", "NAV", "NOFRAMES",
+ "NOSCRIPT", "OL", "OUTPUT", "P", "PRE", "SECTION", "SUMMARY", "TABLE", "TBODY", "TD",
+ "TFOOT", "TH", "THEAD", "TR", "UL"
+]
+const NEWLINE_ELEMENTS = BLOCK_ELEMENTS.concat(["BR"])
+
+/**
+ * Determine whether an event is the bridged representation of a discord message.
+ * Such messages shouldn't be bridged again.
+ * @param {string} sender
+ */
+function eventSenderIsFromDiscord(sender) {
+ // If it's from a user in the bridge's namespace, then it originated from discord
+ // This could include messages sent by the appservice's bot user, because that is what's used for webhooks
+ if (userRegex.some(x => sender.match(x))) {
+ return true
+ }
+
+ return false
+}
+
+/**
+ * Event IDs are really big and have more entropy than we need.
+ * If we want to store the event ID in the database, we can store a more compact version by hashing it with this.
+ * I choose a 64-bit non-cryptographic hash as only a 32-bit hash will see birthday collisions unreasonably frequently: https://en.wikipedia.org/wiki/Birthday_attack#Mathematics
+ * xxhash outputs an unsigned 64-bit integer.
+ * Converting to a signed 64-bit integer with no bit loss so that it can be stored in an SQLite integer field as-is: https://www.sqlite.org/fileformat2.html#record_format
+ * This should give very efficient storage with sufficient entropy.
+ * @param {string} eventID
+ */
+function getEventIDHash(eventID) {
+ assert(hasher, "xxhash is not ready yet")
+ if (eventID[0] === "$" && eventID.length >= 13) {
+ eventID = eventID.slice(1) // increase entropy per character to potentially help xxhash
+ }
+ const unsignedHash = hasher.h64(eventID)
+ const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
+ return signedHash
+}
+
+class MatrixStringBuilderStack {
+ constructor() {
+ this.stack = [new MatrixStringBuilder()]
+ }
+
+ get msb() {
+ return this.stack[0]
+ }
+
+ bump() {
+ this.stack.unshift(new MatrixStringBuilder())
+ }
+
+ shift() {
+ const msb = this.stack.shift()
+ assert(msb)
+ return msb
+ }
+}
+
+class MatrixStringBuilder {
+ constructor() {
+ this.body = ""
+ this.formattedBody = ""
+ }
+
+ /**
+ * @param {string} body
+ * @param {string} [formattedBody]
+ * @param {any} [condition]
+ */
+ add(body, formattedBody, condition = true) {
+ if (condition) {
+ if (formattedBody == undefined) formattedBody = tag`${body}`
+ this.body += body
+ this.formattedBody += formattedBody
+ }
+ return this
+ }
+
+ /**
+ * @param {string} body
+ * @param {string} [formattedBody]
+ * @param {any} [condition]
+ */
+ addLine(body, formattedBody, condition = true) {
+ if (condition) {
+ if (formattedBody == undefined) formattedBody = tag`${body}`
+ if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n"
+ this.body += body
+ const match = this.formattedBody.match(/<\/?([a-zA-Z]+[a-zA-Z0-9]*)[^>]*>\s*$/)
+ if (this.formattedBody.length && (!match || !NEWLINE_ELEMENTS.includes(match[1].toUpperCase()))) this.formattedBody += "
"
+ this.formattedBody += formattedBody
+ }
+ return this
+ }
+
+ /**
+ * @param {string} body
+ * @param {string} [formattedBody]
+ * @param {any} [condition]
+ */
+ addParagraph(body, formattedBody, condition = true) {
+ if (condition) {
+ if (formattedBody == undefined) formattedBody = tag`${body}`
+ if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n\n"
+ this.body += body
+ const match = formattedBody.match(/^<([a-zA-Z]+[a-zA-Z0-9]*)/)
+ if (!match || !BLOCK_ELEMENTS.includes(match[1].toUpperCase())) formattedBody = `${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, allCreators: string[], tombstone: number, roomCreate: Ty.Event.StateOuter, powerLevels: Ty.Event.M_Power_Levels}>, getJoinedMembers: import("./api")["getJoinedMembers"]}} 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
+ /* c8 ignore next */
+ const call = "getEffectivePower" in api ? api.getEffectivePower(roomID, [bot], api) : getEffectivePower(roomID, [bot], api)
+ const {allCreators, powerLevels} = await call
+ powerLevels.users ??= {}
+ const sorted = allCreators.concat(Object.entries(powerLevels.users).sort((a, b) => b[1] - a[1]).map(([mxid]) => mxid)) // Highest...
+ for (const mxid of sorted) {
+ if (!(mxid in joined)) continue // joined...
+ if (userRegex.some(r => mxid.match(r))) continue // non-sim non-bot...
+ const match = mxid.match(/:(.*)/)
+ assert(match)
+ /* c8 ignore next - should be already covered by the userRegex test, but let's be explicit */
+ if (candidates.includes(match[1])) continue // from a different server
+ candidates.push(match[1])
+ break
+ }
+ // Candidates 2-3: Most popular servers in the room
+ /** @type {Map} */
+ const servers = new Map()
+ // We can get the most popular servers if we know the members, so let's process those...
+ Object.keys(joined)
+ .filter(mxid => !mxid.startsWith("@_")) // Quick check
+ .filter(mxid => !userRegex.some(r => mxid.match(r))) // Full check
+ .slice(0, 1000) // Just sample the first thousand real members
+ .map(mxid => {
+ const match = mxid.match(/:(.*)/)
+ assert(match)
+ return match[1]
+ })
+ .filter(server => !server.match(/([a-f0-9:]+:+)+[a-f0-9]+/)) // No IPv6 servers
+ .filter(server => !server.match(/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/)) // No IPv4 servers
+ // I don't care enough to check ACLs
+ .forEach(server => {
+ const existing = servers.get(server)
+ if (!existing) servers.set(server, 1)
+ else servers.set(server, existing + 1)
+ })
+ const serverList = [...servers.entries()].sort((a, b) => b[1] - a[1])
+ for (const server of serverList) {
+ if (!candidates.includes(server[0])) {
+ candidates.push(server[0])
+ if (candidates.length >= 4) break // Can have at most 4 candidate via servers
+ }
+ }
+ return candidates
+}
+
+/**
+ * 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]}} api
+ * @returns {Promise}
+ */
+async function getViaServersQuery(roomID, api) {
+ const list = await getViaServers(roomID, api)
+ const qs = new URLSearchParams()
+ for (const server of list) {
+ qs.append("via", server)
+ }
+ return qs
+}
+
+function generatePermittedMediaHash(mxc) {
+ assert(hasher, "xxhash is not ready yet")
+ const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
+ if (!mediaParts) return undefined
+
+ const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}`
+ const unsignedHash = hasher.h64(serverAndMediaID)
+ const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
+ db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash)
+
+ return serverAndMediaID
+}
+
+/**
+ * Since the introduction of authenticated media, this can no longer just be the /_matrix/media/r0/download URL
+ * because Discord and Discord users cannot use those URLs. Media now has to be proxied through the bridge.
+ * To avoid the bridge acting as a proxy for *any* media, there is a list of permitted media stored in the database.
+ * (The other approach would be signing the URLs with a MAC (or similar) and adding the signature, but I'm not a
+ * cryptographer, so I don't want to.) To reduce database disk space usage, instead of storing each permitted URL,
+ * we just store its xxhash as a signed (as in +/-, not signature) 64-bit integer, which fits in an SQLite integer field.
+ * @see https://matrix.org/blog/2024/06/26/sunsetting-unauthenticated-media/ background
+ * @see https://matrix.org/blog/2024/06/20/matrix-v1.11-release/ implementation details
+ * @see https://www.sqlite.org/fileformat2.html#record_format SQLite integer field size
+ * @param {string | null | undefined} mxc
+ * @returns {string | undefined}
+ */
+function getPublicUrlForMxc(mxc) {
+ const serverAndMediaID = makeMxcPublic(mxc)
+ if(!serverAndMediaID) return undefined
+ return `${reg.ooye.bridge_origin}/download/matrix/${serverAndMediaID}`
+}
+
+/**
+ * @param {string | null | undefined} mxc
+ * @returns {string | undefined} mxc URL with protocol stripped, e.g. "cadence.moe/abcdef1234"
+ */
+function makeMxcPublic(mxc) {
+ assert(hasher, "xxhash is not ready yet")
+ const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
+ if (!mediaParts) return undefined
+
+ const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}`
+ const unsignedHash = hasher.h64(serverAndMediaID)
+ const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
+ db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash)
+
+ return serverAndMediaID
+}
+
+/**
+ * @param {string} roomVersionString
+ * @param {number} desiredVersion
+ */
+function roomHasAtLeastVersion(roomVersionString, desiredVersion) {
+ /*
+ I hate this.
+ The spec instructs me to compare room versions ordinally, for example, "In room versions 12 and higher..."
+ So if the real room version is 13, this should pass the check.
+ However, the spec also says "room versions are not intended to be parsed and should be treated as opaque identifiers", "due to versions not being ordered or hierarchical".
+ So versions are unordered and opaque and you can't parse them, but you're still expected to parse them to a number and compare them to another number to measure if it's "12 or higher"?
+ Theoretically MSC3244 would clean this up, but that isn't happening since Element removed support for MSC3244: https://github.com/element-hq/element-web/commit/644b8415912afb9c5eed54859a444a2ee7224117
+ Element replaced it with the following function:
+ */
+
+ // Assumption: all unstable room versions don't support the feature. Calling code can check for unstable
+ // room versions explicitly if it wants to. The spec reserves [0-9] and `.` for its room versions.
+ if (!roomVersionString.match(/^[\d.]+$/)) {
+ return false;
+ }
+
+ // Element dev note: While the spec says room versions are not linear, we can make reasonable assumptions
+ // until the room versions prove themselves to be non-linear in the spec. We should see this coming
+ // from a mile away and can course-correct this function if needed.
+ return Number(roomVersionString) >= Number(desiredVersion);
+}
+
+/**
+ * Starting in room version 12, creators may not be specified in power levels users.
+ * Modifies the input power levels.
+ * @param {Ty.Event.StateOuter} roomCreateOuter
+ * @param {Ty.Event.M_Power_Levels} powerLevels
+ */
+function removeCreatorsFromPowerLevels(roomCreateOuter, powerLevels) {
+ assert(roomCreateOuter.sender)
+ if (roomHasAtLeastVersion(roomCreateOuter.content.room_version, 12) && powerLevels.users) {
+ for (const creator of (roomCreateOuter.content.additional_creators ?? []).concat(roomCreateOuter.sender)) {
+ delete powerLevels.users[creator]
+ }
+ }
+ return powerLevels
+}
+
+/**
+ * @template {string} T
+ * @param {string} roomID
+ * @param {T[]} mxids
+ * @param {{[K in "getStateEvent" | "getStateEventOuter"]: import("./api")[K]}} api
+ * @returns {Promise<{powers: Record, allCreators: string[], tombstone: number, roomCreate: Ty.Event.StateOuter, powerLevels: Ty.Event.M_Power_Levels}>}
+ */
+async function getEffectivePower(roomID, mxids, api) {
+ /** @type {[Ty.Event.StateOuter, Ty.Event.M_Power_Levels]} */
+ const [roomCreate, powerLevels] = await Promise.all([
+ api.getStateEventOuter(roomID, "m.room.create", ""),
+ api.getStateEvent(roomID, "m.room.power_levels", "")
+ ])
+ const allCreators =
+ ( roomHasAtLeastVersion(roomCreate.content.room_version, 12) ? (roomCreate.content.additional_creators ?? []).concat(roomCreate.sender)
+ : [])
+ const tombstone =
+ ( roomHasAtLeastVersion(roomCreate.content.room_version, 12) ? powerLevels.events?.["m.room.tombstone"] ?? 150
+ : powerLevels.events?.["m.room.tombstone"] ?? powerLevels.state_default ?? 50)
+ /** @type {Record} */ // @ts-ignore
+ const powers = {}
+ for (const mxid of mxids) {
+ powers[mxid] =
+ ( roomHasAtLeastVersion(roomCreate.content.room_version, 12) && allCreators.includes(mxid) ? Infinity
+ : powerLevels.users?.[mxid]
+ ?? powerLevels.users_default
+ ?? 0)
+ }
+ return {powers, allCreators, tombstone, roomCreate, powerLevels}
+}
+
+/**
+ * Set a user's power level within a room.
+ * @param {string} roomID
+ * @param {string} mxid
+ * @param {number} newPower
+ * @param {{[K in "getStateEvent" | "getStateEventOuter" | "sendState"]: import("./api")[K]}} api
+ */
+async function setUserPower(roomID, mxid, newPower, api) {
+ assert(roomID[0] === "!")
+ assert(mxid[0] === "@")
+ // Yes there's no shortcut https://github.com/matrix-org/matrix-appservice-bridge/blob/2334b0bae28a285a767fe7244dad59f5a5963037/src/components/intent.ts#L352
+ const {powerLevels, powers: {[mxid]: oldPowerLevel, [bot]: botPowerLevel}} = await getEffectivePower(roomID, [mxid, bot], api)
+
+ // Check if it has really changed to avoid sending a useless state event
+ if (oldPowerLevel === newPower) return
+
+ // Bridge bot can't demote equal power users, so need to decide which user will send the event
+ const eventSender = oldPowerLevel >= botPowerLevel ? mxid : undefined
+
+ // Update the event content
+ powerLevels.users ??= {}
+ if (newPower == null || newPower === (powerLevels.users_default ?? 0)) {
+ delete powerLevels.users[mxid]
+ } else {
+ powerLevels.users[mxid] = newPower
+ }
+
+ await api.sendState(roomID, "m.room.power_levels", "", powerLevels, eventSender)
+}
+
+/**
+ * Set a user's power level for a whole room hierarchy.
+ * @param {string} spaceID
+ * @param {string} mxid
+ * @param {number} power
+ * @param {{[K in "getStateEvent" | "getStateEventOuter" | "sendState" | "generateFullHierarchy"]: import("./api")[K]}} api
+ */
+async function setUserPowerCascade(spaceID, mxid, power, api) {
+ assert(spaceID[0] === "!")
+ assert(mxid[0] === "@")
+ let seenSpace = false
+ for await (const room of api.generateFullHierarchy(spaceID)) {
+ if (room.room_id === spaceID) seenSpace = true
+ await setUserPower(room.room_id, mxid, power, api)
+ }
+ if (!seenSpace) {
+ await setUserPower(spaceID, mxid, power, api)
+ }
+}
+
+module.exports.bot = bot
+module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS
+module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
+module.exports.makeMxcPublic = makeMxcPublic
+module.exports.getPublicUrlForMxc = getPublicUrlForMxc
+module.exports.getEventIDHash = getEventIDHash
+module.exports.MatrixStringBuilder = MatrixStringBuilder
+module.exports.MatrixStringBuilderStack = MatrixStringBuilderStack
+module.exports.getViaServers = getViaServers
+module.exports.getViaServersQuery = getViaServersQuery
+module.exports.roomHasAtLeastVersion = roomHasAtLeastVersion
+module.exports.removeCreatorsFromPowerLevels = removeCreatorsFromPowerLevels
+module.exports.getEffectivePower = getEffectivePower
+module.exports.setUserPower = setUserPower
+module.exports.setUserPowerCascade = setUserPowerCascade
diff --git a/src/matrix/utils.test.js b/src/matrix/utils.test.js
new file mode 100644
index 00000000..842c5130
--- /dev/null
+++ b/src/matrix/utils.test.js
@@ -0,0 +1,420 @@
+// @ts-check
+
+const {select} = require("../passthrough")
+const {test} = require("supertape")
+const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion, removeCreatorsFromPowerLevels, setUserPower} = require("./utils")
+const util = require("util")
+
+/** @param {string[]} mxids */
+function joinedList(mxids) {
+ /** @type {{[mxid: string]: {display_name: null, avatar_url: null}}} */
+ const joined = {}
+ for (const mxid of mxids) {
+ joined[mxid] = {
+ display_name: null,
+ avatar_url: null
+ }
+ }
+ return {joined}
+}
+
+test("sender type: matrix user", t => {
+ t.notOk(eventSenderIsFromDiscord("@cadence:cadence.moe"))
+})
+
+test("sender type: ooye bot", t => {
+ t.ok(eventSenderIsFromDiscord("@_ooye_bot:cadence.moe"))
+})
+
+test("sender type: ooye puppet", t => {
+ t.ok(eventSenderIsFromDiscord("@_ooye_sheep:cadence.moe"))
+})
+
+test("event hash: hash is the same each time", t => {
+ const eventID = "$example"
+ t.equal(getEventIDHash(eventID), getEventIDHash(eventID))
+})
+
+test("event hash: hash is different for different inputs", t => {
+ t.notEqual(getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe1"), getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe2"))
+})
+
+test("MatrixStringBuilder: add, addLine, add same text", t => {
+ const e = {
+ stack: "Error: Custom error\n at ./example.test.js:3:11)",
+ toString() {
+ return "Error: Custom error"
+ }
+ }
+ const gatewayMessage = {t: "MY_MESSAGE", d: {display: "Custom message data"}}
+ let stackLines = e.stack.split("\n")
+
+ const builder = new MatrixStringBuilder()
+ builder.addLine("\u26a0 Bridged event from Discord not delivered", "\u26a0 Bridged event from Discord not delivered")
+ builder.addLine(`Gateway event: ${gatewayMessage.t}`)
+ builder.addLine(e.toString())
+ if (stackLines) {
+ stackLines = stackLines.slice(0, 2)
+ stackLines[1] = stackLines[1].replace(/\\/g, "/").replace(/(\s*at ).*(\/m2d\/)/, "$1.$2")
+ builder.addLine(`Error trace:`, `Error trace
`)
+ builder.add(`\n${stackLines.join("\n")}`, `${stackLines.join("\n")}`)
+ }
+ builder.addLine("", `Original payload
${util.inspect(gatewayMessage.d, false, 4, false)}`)
+
+ t.deepEqual(builder.get(), {
+ msgtype: "m.text",
+ body: "\u26a0 Bridged event from Discord not delivered"
+ + "\nGateway event: MY_MESSAGE"
+ + "\nError: Custom error"
+ + "\nError trace:"
+ + "\nError: Custom error"
+ + "\n at ./example.test.js:3:11)\n",
+ format: "org.matrix.custom.html",
+ formatted_body: "\u26a0 Bridged event from Discord not delivered"
+ + "
Gateway event: MY_MESSAGE"
+ + "
Error: Custom error"
+ + "
Error trace
Error: Custom error\n at ./example.test.js:3:11)
"
+ + `Original payload
{ display: 'Custom message data' }`
+ })
+})
+
+test("MatrixStringBuilder: complete code coverage", t => {
+ const builder = new MatrixStringBuilder()
+ builder.add("Line 1")
+ builder.addParagraph("Line 2")
+ builder.add("Line 3")
+ builder.addParagraph("Line 4")
+
+ t.deepEqual(builder.get(), {
+ msgtype: "m.text",
+ body: "Line 1\n\nLine 2Line 3\n\nLine 4",
+ format: "org.matrix.custom.html",
+ formatted_body: "Line 1Line 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 c7cb006f..a85907d5 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
@@ -32,6 +34,7 @@ export type AppServiceRegistrationConfig = {
discord_cdn_origin?: string,
web_password: string
time_zone?: string
+ receive_presences: boolean
}
old_bridge?: {
as_token: string
@@ -64,6 +67,7 @@ export type InitialAppServiceRegistrationConfig = {
content_length_workaround: boolean
invite: string[]
include_user_id_in_mxid: boolean
+ receive_presences: boolean
}
}
@@ -72,6 +76,13 @@ export type WebhookCreds = {
token: string
}
+/** Discord API message->author. A webhook as an author. */
+export type WebhookAuthor = {
+ username: string
+ avatar: string | null
+ id: string
+}
+
export type PkSystem = {
id: string
uuid: string
@@ -134,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
@@ -157,6 +153,37 @@ export namespace Event {
content: any
}
+ export type InviteStrippedState = {
+ type: string
+ state_key: string
+ sender: string
+ content: Event.M_Room_Create | Event.M_Room_Name | Event.M_Room_Avatar | Event.M_Room_Topic | Event.M_Room_JoinRules | Event.M_Room_CanonicalAlias
+ }
+
+ export type M_Room_Create = {
+ additional_creators?: string[]
+ "m.federate"?: boolean
+ room_version: string
+ type?: string
+ predecessor?: {
+ room_id: string
+ event_id?: string
+ }
+ }
+
+ export type M_Room_JoinRules = {
+ join_rule: "public" | "knock" | "invite" | "private" | "restricted" | "knock_restricted"
+ allow?: {
+ type: string
+ room_id: string
+ }[]
+ }
+
+ export type M_Room_CanonicalAlias = {
+ alias?: string
+ alt_aliases?: string[]
+ }
+
export type M_Room_Message = {
msgtype: "m.text" | "m.emote"
body: string
@@ -181,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
@@ -198,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
@@ -242,6 +271,49 @@ export namespace Event {
export type Outer_M_Sticker = Outer & {type: "m.sticker"}
+ export type Org_Matrix_Msc3381_Poll_Start = {
+ "org.matrix.msc3381.poll.start": {
+ question: {
+ "org.matrix.msc1767.text": string
+ body: string
+ msgtype: string
+ },
+ kind: string
+ max_selections: number
+ answers: {
+ id: string
+ "org.matrix.msc1767.text": string
+ }[]
+ "org.matrix.msc1767.text": string
+ }
+ }
+
+ export type Outer_Org_Matrix_Msc3381_Poll_Start = Outer & {type: "org.matrix.msc3381.poll.start"}
+
+ export type Org_Matrix_Msc3381_Poll_Response = {
+ "org.matrix.msc3381.poll.response": {
+ answers: string[]
+ }
+ "m.relates_to": {
+ rel_type: string
+ event_id: string
+ }
+ }
+
+ export type Outer_Org_Matrix_Msc3381_Poll_Response = Outer & {type: "org.matrix.msc3381.poll.response"}
+
+ export type Org_Matrix_Msc3381_Poll_End = {
+ "org.matrix.msc3381.poll.end": {},
+ "org.matrix.msc1767.text": string,
+ body: string,
+ "m.relates_to": {
+ rel_type: string
+ event_id: string
+ }
+ }
+
+ export type Outer_Org_Matrix_Msc3381_Poll_End = Outer & {type: "org.matrix.msc3381.poll.end"}
+
export type M_Room_Member = {
membership: string
displayname?: string
@@ -249,7 +321,6 @@ export namespace Event {
}
export type M_Room_Avatar = {
- discord_path?: string
url?: string
}
@@ -314,6 +385,11 @@ export namespace Event {
}> & {
redacts: string
}
+
+ export type M_Room_Tombstone = {
+ body: string
+ replacement_room: string
+ }
}
export namespace R {
@@ -357,6 +433,7 @@ export namespace R {
guest_can_join: boolean
join_rule?: string
name?: string
+ topic?: string
num_joined_members: number
room_id: string
room_type?: string
@@ -366,12 +443,68 @@ export namespace R {
room_id: string
servers: string[]
}
+
+ export type SSS = {
+ pos: string
+ lists: {
+ [list_key: string]: {
+ count: number
+ }
+ }
+ rooms: {
+ [room_id: string]: {
+ bump_stamp: number
+ /** Omitted if user not in room (peeking) */
+ membership?: Membership
+ /** Names of lists that match this room */
+ lists: string[]
+ }
+ // If user has been in the room - at least, that's what the spec says. Synapse returns some of these, such as `name` and `avatar`, for invites as well. Go nuts.
+ & {
+ name?: string
+ avatar?: string
+ heroes?: any[]
+ /** According to account data */
+ is_dm?: boolean
+ /** If false, omitted fields are unchanged from their previous value. If true, omitted fields means the fields are not set. */
+ initial?: boolean
+ expanded_timeline?: boolean
+ required_state?: Event.StateOuter[]
+ timeline_events?: Event.Outer[]
+ prev_batch?: string
+ limited?: boolean
+ num_live?: number
+ joined_count?: number
+ invited_count?: number
+ notification_count?: number
+ highlight_count?: number
+ }
+ // If user is invited or knocked
+ & ({
+ /** @deprecated */
+ invite_state: Event.InviteStrippedState[]
+ } | {
+ stripped_state: Event.InviteStrippedState[]
+ })
+ }
+ extensions: {
+ [extension_key: string]: any
+ }
+ }
}
+export type Membership = "invite" | "knock" | "join" | "leave" | "ban"
+
export type Pagination = {
chunk: T[]
next_batch?: string
- prev_match?: string
+ prev_batch?: string
+}
+
+export type MessagesPagination = {
+ chunk: T[]
+ start: string
+ end?: string
}
export type HierarchyPagination = {
diff --git a/src/web/pug-sync.js b/src/web/pug-sync.js
index f49f5a28..f87550df 100644
--- a/src/web/pug-sync.js
+++ b/src/web/pug-sync.js
@@ -31,7 +31,15 @@ function addGlobals(obj) {
*/
function render(event, filename, locals) {
const path = join(__dirname, "pug", filename)
+ return renderPath(event, path, locals)
+}
+/**
+ * @param {import("h3").H3Event} event
+ * @param {string} path
+ * @param {Record} locals
+ */
+function renderPath(event, path, locals) {
function compile() {
try {
const template = compileFile(path, {pretty})
@@ -89,4 +97,5 @@ function createRoute(router, url, filename) {
module.exports.addGlobals = addGlobals
module.exports.render = render
+module.exports.renderPath = renderPath
module.exports.createRoute = createRoute
diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug
index 68e53a84..a9e770b0 100644
--- a/src/web/pug/guild.pug
+++ b/src/web/pug/guild.pug
@@ -13,7 +13,7 @@ mixin badge-private
mixin discord(channel, radio=false)
//- Previously, we passed guild.roles as the second parameter, but this doesn't quite match Discord's behaviour. See issue #42 for why this was changed.
//- Basically we just want to assign badges based on the channel overwrites, without considering the guild's base permissions. /shrug
- - let permissions = dUtils.getPermissions([], [{id: guild_id, name: "@everyone", permissions: 1<<10 | 1<<11}], null, channel.permission_overwrites)
+ - let permissions = dUtils.getPermissions(guild_id, [], [{id: guild_id, name: "@everyone", permissions: 1<<10 | 1<<11}], null, channel.permission_overwrites)
.s-user-card.s-user-card__small
if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel)
!= icons.Icons.IconLock
@@ -75,6 +75,7 @@ block body
button.s-btn(class=space_id ? "s-btn__muted" : "s-btn__filled" hx-get=rel(`/qr?guild_id=${guild_id}`) hx-indicator="closest button" hx-swap="outerHTML" hx-disabled-elt="this") Show QR
if space_id
+ h2.mt48.fs-headline1 Server settings
h3.mt32.fs-category Privacy level
span#privacy-level-loading
.s-card
@@ -104,7 +105,7 @@ block body
p.s-description.m0 Shareable invite links, like Discord
p.s-description.m0 Publicly listed in directory, like Discord server discovery
- h2.mt48.fs-headline1 Features
+ h3.mt32.fs-category Features
.s-card.d-grid.px0.g16
form.d-flex.ai-center.g16
#url-preview-loading.p8
@@ -124,6 +125,15 @@ block body
| Show online statuses on Matrix
p.s-description This might cause lag on really big Discord servers.
+ form.d-flex.ai-center.g16
+ #webhook-profile-loading.p8
+ - value = !!select("guild_space", "webhook_profile", {guild_id}).pluck().get()
+ input(type="hidden" name="guild_id" value=guild_id)
+ input.s-toggle-switch#webhook-profile(name="webhook_profile" type="checkbox" hx-post=rel("/api/webhook-profile") hx-indicator="#webhook-profile-loading" hx-disabled-elt="this" checked=value autocomplete="off")
+ label.s-label(for="webhook-profile")
+ | Create persistent Matrix sims for webhooks
+ p.s-description Useful when using other Discord bridges. Otherwise, not ideal, as sims will clutter the Matrix user list and will never be cleaned up.
+
if space_id
h2.mt48.fs-headline1 Channel setup
@@ -176,18 +186,19 @@ block body
!= icons.Icons.IconMerge
= ` Link`
- h3.mt32.fs-category Unlink server
- form.s-card.d-flex.fd-row-reverse.gx24.pl24.ai-center(method="post" action=rel("/api/unlink-space"))
- input(type="hidden" name="guild_id" value=guild.id)
- .fl-grow1.s-prose.s-prose__sm.lh-xl
- p.
- Sick of this bridge, or just made a mistake? You can unlink the whole server and all its channels.#[br]
- This may take a minute to process. Please be patient and wait until the page refreshes.
- div
- button.s-btn.s-btn__icon.s-btn__danger.s-btn__outlined(cx-prevent-default hx-post=rel("/api/unlink-space") hx-confirm="Do you want to unlink this server and all its channels?\nIt may take a minute to clean up Matrix resources." hx-indicator="this" hx-disabled-elt="this")
- != icons.Icons.IconUnsync
- span.ml4= ` Unlink`
+ h3.mt32.fs-category Unlink server
+ form.s-card.d-flex.gx16.ai-center(method="post" action=rel("/api/unlink-space"))
+ input(type="hidden" name="guild_id" value=guild.id)
+ .fl-grow1.s-prose.s-prose__sm.lh-lg
+ p.fc-medium.
+ Not using this bridge, or just made a mistake? You can unlink the whole server and all its channels.#[br]
+ This may take a minute to process. Please be patient and wait until the page refreshes.
+ div
+ button.s-btn.s-btn__icon.s-btn__danger.s-btn__outlined(cx-prevent-default hx-post=rel("/api/unlink-space") hx-confirm="Do you want to unlink this server and all its channels?\nIt may take a minute to clean up Matrix resources." hx-indicator="this" hx-disabled-elt="this")
+ != icons.Icons.IconUnsync
+ span.ml4= ` Unlink`
+ if space_id
details.mt48
summary Debug room list
.d-grid.grid__2.gx24
diff --git a/src/web/pug/guild_not_linked.pug b/src/web/pug/guild_not_linked.pug
index 59de2fb3..04d2dae3 100644
--- a/src/web/pug/guild_not_linked.pug
+++ b/src/web/pug/guild_not_linked.pug
@@ -4,7 +4,7 @@ mixin space(space)
.s-user-card.flex__1
span.s-avatar.s-avatar__32.s-user-card--avatar
if space.avatar
- img.s-avatar--image(src=mUtils.getPublicUrlForMxc(space.avatar))
+ img.s-avatar--image(src=mUtils.getPublicUrlForMxc(space.avatar) alt="")
else
.s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= space.name[0]
.s-user-card--info.ai-start
@@ -42,12 +42,23 @@ block body
| You need to log in with Matrix first.
a.s-btn.s-btn__matrix.s-btn__outlined(href=rel(`/log-in-with-matrix`, {next: `./guild?guild_id=${guild_id}`})) Log in with Matrix
- h3.mt48.fs-category Auto-create
- .s-card
+ h3.mt48.fs-category Other choices
+ .s-card.d-grid.g16
form.d-flex.ai-center.g8(method="post" action=rel("/api/autocreate") hx-post=rel("/api/autocreate") hx-indicator="#easy-mode-button")
input(type="hidden" name="guild_id" value=guild_id)
input(type="hidden" name="autocreate" value="true")
label.s-label.fl-grow1
- | Changed your mind?
+ | Do it automatically
p.s-description If you want, OOYE can create and manage the Matrix space so you don't have to.
- button.s-btn.s-btn__outlined#easy-mode-button Use easy mode
+ button.s-btn.s-btn__icon.s-btn__outlined#easy-mode-button
+ != icons.Icons.IconWand
+ span.ml4= ` Use easy mode`
+
+ form.d-flex.gx16.ai-center(method="post" action=rel("/api/unlink-space"))
+ input(type="hidden" name="guild_id" value=guild.id)
+ label.s-label.fl-grow1
+ | Cancel
+ p.s-description Don't want to link this server after all? Here's the button for you.
+ button.s-btn.s-btn__icon.s-btn__muted.s-btn__outlined(cx-prevent-default hx-post=rel("/api/unlink-space") hx-indicator="this" hx-disabled-elt="this")
+ != icons.Icons.IconUnsync
+ span.ml4= ` Unlink`
diff --git a/src/web/pug/home.pug b/src/web/pug/home.pug
index d5622502..8b865331 100644
--- a/src/web/pug/home.pug
+++ b/src/web/pug/home.pug
@@ -41,16 +41,18 @@ block body
= ` Set up self-service`
.s-prose
- h2 What is this?
- p #[a(href="https://gitdab.com/cadence/out-of-your-element") Out Of Your Element] is a bridge between the Discord and Matrix chat apps. It lets people on both platforms chat with each other without needing to get everyone on the same app.
- p Just chat like usual, and the bridge will forward messages back and forth between the two platforms, so everyone sees the whole conversation.
- p All kinds of content are supported, including pictures, threads, emojis, and @mentions.
- p It's really easy to set up, even if you only have Discord. Just add the bot to your server, and it'll make everything available on Matrix automatically.
+ block bridge-info
+ h2 What is this?
+ p #[a(href="https://gitdab.com/cadence/out-of-your-element") Out Of Your Element] is a bridge between the Discord and Matrix chat apps. It lets people on both platforms chat with each other without needing to get everyone on the same app.
+ p Just chat like usual, and the bridge will forward messages back and forth between the two platforms, so everyone sees the whole conversation.
+ p All kinds of content are supported, including pictures, threads, emojis, and @mentions.
+ p It's really easy to set up, even if you only have Discord. Just add the bot to your server, and it'll make everything available on Matrix automatically.
if locked
- h2 This is a private instance
- p Anybody can run their own instance of the Out Of Your Element software. The person running this instance has made it private, so you can't add it to your server just yet. If you know who's in charge of #{reg.ooye.server_name}, ask them for the password.
+ block locked-info
+ h2 This is a private instance
+ p Anybody can run their own instance of the Out Of Your Element software. The person running this instance has made it private, so you can't add it to your server just yet. If you know who's in charge of #{reg.ooye.server_name}, ask them for the password.
- h2 Run your own instance
- p You can still use Out Of Your Element by running your own copy of the software, but this requires some technical skill.
- p To get started, #[a(href="https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md") check the installation instructions.]
+ h2 Run your own instance
+ p You can still use Out Of Your Element by running your own copy of the software, but this requires some technical skill.
+ p To get started, #[a(href="https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md") check the installation instructions.]
diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug
index 4d424c21..9fe80aad 100644
--- a/src/web/pug/includes/template.pug
+++ b/src/web/pug/includes/template.pug
@@ -1,13 +1,23 @@
-mixin guild(guild)
+mixin guild-menuitem(guild)
+ - let bridgedRoomCount = from("channel_room").selectUnsafe("count(*) as count").where({guild_id: guild.id}).and("AND thread_parent IS NULL").get().count
+ li(role="menuitem")
+ a.s-topbar--item.s-user-card.d-flex.p4(href=rel(`/guild?guild_id=${guild.id}`) class={"bg-purple-200": bridgedRoomCount === 0, "h:bg-purple-300": bridgedRoomCount === 0})
+ +guild(guild, bridgedRoomCount)
+
+mixin guild(guild, bridgedRoomCount)
span.s-avatar.s-avatar__32.s-user-card--avatar
if guild.icon
- img.s-avatar--image(src=`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=32`)
+ img.s-avatar--image(src=`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=32` alt="")
else
.s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= guild.name[0]
.s-user-card--info.ai-start
strong= guild.name
- ul.s-user-card--awards
- li #{discord.guildChannelMap.get(guild.id).filter(c => [0, 5, 15, 16].includes(discord.channels.get(c).type)).length} channels
+ if bridgedRoomCount != null
+ ul.s-user-card--awards
+ if bridgedRoomCount
+ li #{bridgedRoomCount} bridged rooms
+ else
+ li.fc-purple Not yet linked
mixin define-theme(name, h, s, l)
style.
@@ -58,6 +68,8 @@ html(lang="en")
title Out Of Your Element
link(rel="stylesheet" type="text/css" href=rel("/static/stacks.min.css"))
+ //- Please use responsibly!!!!!
+ link(rel="stylesheet" type="text/css" href=rel("/custom.css"))
meta(name="htmx-config" content='{"requestClass":"is-loading"}')
style.
@@ -79,13 +91,21 @@ html(lang="en")
.s-btn__dropdown:has(+ :popover-open) {
background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important;
}
+ @media (prefers-color-scheme: dark) {
+ body.theme-system .s-popover {
+ --_po-bg: var(--black-100);
+ --_po-bc: var(--bc-light);
+ --_po-bs: var(--bs-lg);
+ --_po-arrow-fc: var(--black-100);
+ }
+ }
+define-themed-button("matrix", "black")
body.themed.theme-system
header.s-topbar
- .s-topbar--skip-link(href="#content") Skip to main content
+ a.s-topbar--skip-link(href="#content") Skip to main content
.s-topbar--container.wmx9
a.s-topbar--logo(href=rel("/"))
- img.s-avatar.s-avatar__32(src=rel("/icon.png"))
+ img.s-avatar.s-avatar__32(src=rel("/icon.png") alt="")
nav.s-topbar--navigation
ul.s-topbar--content
li.ps-relative.g8
@@ -114,9 +134,7 @@ html(lang="en")
.s-popover--content.overflow-y-auto.overflow-x-hidden
ul.s-menu(role="menu")
each guild in [...managed].map(id => discord.guilds.get(id)).filter(g => g).sort((a, b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1)
- li(role="menuitem")
- a.s-topbar--item.s-user-card.d-flex.p4(href=rel(`/guild?guild_id=${guild.id}`))
- +guild(guild)
+ +guild-menuitem(guild)
//- Body
.mx-auto.w100.wmx9.py24.px8.fs-body1#content
block body
diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js
index bbf33b08..769fc9c0 100644
--- a/src/web/routes/download-discord.js
+++ b/src/web/routes/download-discord.js
@@ -38,7 +38,6 @@ function timeUntilExpiry(url) {
assert(ex) // refreshed urls from the discord api always include this parameter
const time = parseInt(ex, 16)*1000 - Date.now()
if (time > 0) return time
- return false
}
function defineMediaProxyHandler(domain) {
@@ -71,6 +70,7 @@ function defineMediaProxyHandler(domain) {
refreshed = await promise
const time = timeUntilExpiry(refreshed)
assert(time) // the just-refreshed URL will always be in the future
+ /* c8 ignore next 3 */
setTimeout(() => {
cache.delete(url)
}, time).unref()
@@ -83,3 +83,5 @@ function defineMediaProxyHandler(domain) {
as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com"))
as.router.get(`/download/discordmedia/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net"))
+
+module.exports._cache = cache
diff --git a/src/web/routes/download-discord.test.js b/src/web/routes/download-discord.test.js
index b0b0077e..e4f4ab4d 100644
--- a/src/web/routes/download-discord.test.js
+++ b/src/web/routes/download-discord.test.js
@@ -1,23 +1,10 @@
// @ts-check
+const assert = require("assert").strict
const tryToCatch = require("try-to-catch")
const {test} = require("supertape")
const {router} = require("../../../test/web")
-const {MatrixServerError} = require("../../matrix/mreq")
-
-const snow = {
- channel: {
- async refreshAttachmentURLs(attachments) {
- if (typeof attachments === "string") attachments = [attachments]
- return {
- refreshed_urls: attachments.map(a => ({
- original: a,
- refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}`
- }))
- }
- }
- }
-}
+const {_cache} = require("./download-discord")
test("web download discord: access denied if not a known attachment", async t => {
const [error] = await tryToCatch(() =>
@@ -26,8 +13,7 @@ test("web download discord: access denied if not a known attachment", async t =>
channel_id: "1",
attachment_id: "2",
file_name: "image.png"
- },
- snow
+ }
})
)
t.ok(error)
@@ -42,8 +28,70 @@ test("web download discord: works if a known attachment", async t => {
file_name: "image.png"
},
event,
- snow
+ snow: {
+ channel: {
+ async refreshAttachmentURLs(attachments) {
+ assert(Array.isArray(attachments))
+ return {
+ refreshed_urls: attachments.map(a => ({
+ original: a,
+ refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}`
+ }))
+ }
+ }
+ }
+ }
})
t.equal(event.node.res.statusCode, 302)
t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/)
})
+
+test("web download discord: uses cache", async t => {
+ let notCalled = true
+ const event = {}
+ await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", {
+ params: {
+ channel_id: "655216173696286746",
+ attachment_id: "1314358913482621010",
+ file_name: "image.png"
+ },
+ event,
+ snow: {
+ channel: {
+ /* c8 ignore next 4 */
+ async refreshAttachmentURLs(attachments) {
+ notCalled = false
+ throw new Error("tried to refresh when it should be in cache")
+ }
+ }
+ }
+ })
+ t.ok(notCalled)
+})
+
+test("web download discord: refreshes when cache has expired", async t => {
+ _cache.set(`https://cdn.discordapp.com/attachments/655216173696286746/1314358913482621010/image.png`, Promise.resolve(`https://cdn.discordapp.com/blah?ex=${Math.floor(new Date("2026-01-01").getTime() / 1000 + 3600).toString(16)}`))
+ let called = 0
+ await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", {
+ params: {
+ channel_id: "655216173696286746",
+ attachment_id: "1314358913482621010",
+ file_name: "image.png"
+ },
+ snow: {
+ channel: {
+ async refreshAttachmentURLs(attachments) {
+ called++
+ assert(Array.isArray(attachments))
+ return {
+ refreshed_urls: attachments.map(a => ({
+ original: a,
+ refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}`
+ }))
+ }
+ }
+ }
+ }
+ })
+ t.equal(called, 1)
+})
diff --git a/src/web/routes/download-matrix.js b/src/web/routes/download-matrix.js
index 8f790c5a..82e2f7e6 100644
--- a/src/web/routes/download-matrix.js
+++ b/src/web/routes/download-matrix.js
@@ -1,7 +1,7 @@
// @ts-check
const assert = require("assert/strict")
-const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, sendStream, createError, H3Event} = require("h3")
+const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, createError, H3Event, getValidatedQuery} = require("h3")
const {z} = require("zod")
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
@@ -11,10 +11,25 @@ require("xxhash-wasm")().then(h => hasher = h)
const {sync, as, select} = require("../../passthrough")
+/** @type {import("../../m2d/actions/emoji-sheet")} */
+const emojiSheet = sync.require("../../m2d/actions/emoji-sheet")
+/** @type {import("../../m2d/converters/emoji-sheet")} */
+const emojiSheetConverter = sync.require("../../m2d/converters/emoji-sheet")
+
+/** @type {import("../../m2d/actions/sticker")} */
+const sticker = sync.require("../../m2d/actions/sticker")
+
const schema = {
params: z.object({
server_name: z.string(),
media_id: z.string()
+ }),
+ sheet: z.object({
+ e: z.array(z.string()).or(z.string())
+ }),
+ sticker: z.object({
+ server_name: z.string().regex(/^[^/]+$/),
+ media_id: z.string().regex(/^[A-Za-z0-9_-]+$/)
})
}
@@ -27,10 +42,16 @@ function getAPI(event) {
return event.context.api || sync.require("../../matrix/api")
}
-as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(async event => {
- const params = await getValidatedRouterParams(event, schema.params.parse)
+/**
+ * @param {H3Event} event
+ * @returns {typeof emojiSheet["getAndConvertEmoji"]}
+ */
+function getMxcDownloader(event) {
+ /* c8 ignore next */
+ return event.context.mxcDownloader || emojiSheet.getAndConvertEmoji
+}
- const serverAndMediaID = `${params.server_name}/${params.media_id}`
+function verifyMediaHash(serverAndMediaID) {
const unsignedHash = hasher.h64(serverAndMediaID)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
@@ -41,7 +62,12 @@ as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(asyn
data: `The file you requested isn't permitted by this media proxy.`
})
}
+}
+as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(async event => {
+ const params = await getValidatedRouterParams(event, schema.params.parse)
+
+ verifyMediaHash(`${params.server_name}/${params.media_id}`)
const api = getAPI(event)
const res = await api.getMedia(`mxc://${params.server_name}/${params.media_id}`)
@@ -53,3 +79,32 @@ as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(asyn
setResponseHeader(event, "Transfer-Encoding", "chunked")
return res.body
}))
+
+as.router.get(`/download/sheet`, defineEventHandler(async event => {
+ const query = await getValidatedQuery(event, schema.sheet.parse)
+
+ /** remember that these have no mxc:// protocol in the string for space reasons */
+ let mxcs = query.e
+ if (!Array.isArray(mxcs)) {
+ mxcs = [mxcs]
+ }
+
+ for (const serverAndMediaID of mxcs) {
+ verifyMediaHash(serverAndMediaID)
+ }
+
+ const buffer = await emojiSheetConverter.compositeMatrixEmojis(mxcs.map(s => `mxc://${s}`), getMxcDownloader(event))
+ setResponseHeader(event, "Content-Type", "image/png")
+ return buffer
+}))
+
+as.router.get(`/download/sticker/:server_name/:media_id/_.webp`, defineEventHandler(async event => {
+ const {server_name, media_id} = await getValidatedRouterParams(event, schema.sticker.parse)
+ /** remember that this has no mxc:// protocol in the string */
+ const mxc = server_name + "/" + media_id
+ verifyMediaHash(mxc)
+
+ const stream = await sticker.getAndResizeSticker(`mxc://${mxc}`)
+ setResponseHeader(event, "Content-Type", "image/webp")
+ return stream
+}))
diff --git a/src/web/routes/download-matrix.test.js b/src/web/routes/download-matrix.test.js
index 421d2da7..ccbcfddf 100644
--- a/src/web/routes/download-matrix.test.js
+++ b/src/web/routes/download-matrix.test.js
@@ -1,8 +1,11 @@
// @ts-check
+const fs = require("fs")
+const {convertImageStream} = require("../../m2d/converters/emoji-sheet")
const tryToCatch = require("try-to-catch")
const {test} = require("supertape")
const {router} = require("../../../test/web")
+const streamWeb = require("stream/web")
test("web download matrix: access denied if not a known attachment", async t => {
const [error] = await tryToCatch(() =>
@@ -25,6 +28,7 @@ test("web download matrix: works if a known attachment", async t => {
},
event,
api: {
+ // @ts-ignore
async getMedia(mxc, init) {
return new Response("", {status: 200, headers: {"content-type": "image/png"}})
}
@@ -33,3 +37,52 @@ test("web download matrix: works if a known attachment", async t => {
t.equal(event.node.res.statusCode, 200)
t.equal(event.node.res.getHeader("content-type"), "image/png")
})
+
+/**
+ * MOCK: Gets the emoji from the filesystem and converts to uncompressed PNG data.
+ * @param {string} mxc a single mxc:// URL
+ * @returns {Promise} uncompressed PNG data, or undefined if the downloaded emoji is not valid
+*/
+async function mockGetAndConvertEmoji(mxc) {
+ const id = mxc.match(/\/([^./]*)$/)?.[1]
+ let s
+ if (fs.existsSync(`test/res/${id}.png`)) {
+ s = fs.createReadStream(`test/res/${id}.png`)
+ } else {
+ s = fs.createReadStream(`test/res/${id}.gif`)
+ }
+ return convertImageStream(s, () => {
+ s.pause()
+ s.emit("end")
+ })
+}
+
+test("web sheet: single emoji", async t => {
+ const event = {}
+ const sheet = await router.test("get", "/download/sheet?e=cadence.moe%2FRLMgJGfgTPjIQtvvWZsYjhjy", {
+ event,
+ mxcDownloader: mockGetAndConvertEmoji
+ })
+ t.equal(event.node.res.statusCode, 200)
+ t.equal(sheet.subarray(0, 90).toString("base64"), "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALoklEQVR4nM1ZaVBU2RU+LZSIGnAvFUtcRkSk6abpbkDH")
+})
+
+test("web sheet: multiple sources", async t => {
+ const event = {}
+ const sheet = await router.test("get", "/download/sheet?e=cadence.moe%2FWbYqNlACRuicynBfdnPYtmvc&e=cadence.moe%2FHYcztccFIPgevDvoaWNsEtGJ", {
+ event,
+ mxcDownloader: mockGetAndConvertEmoji
+ })
+ t.equal(event.node.res.statusCode, 200)
+ t.equal(sheet.subarray(0, 90).toString("base64"), "iVBORw0KGgoAAAANSUhEUgAAAGAAAAAwCAYAAADuFn/PAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAT/klEQVR4nOVcC3CVRZbuS2KAIMpDQt5PQkIScm/uvYRX")
+})
+
+test("web sheet: big sheet", async t => {
+ const event = {}
+ const sheet = await router.test("get", "/download/sheet?e=cadence.moe%2FlHfmJpzgoNyNtYHdAmBHxXix&e=cadence.moe%2FMtRdXixoKjKKOyHJGWLsWLNU&e=cadence.moe%2FHXfFuougamkURPPMflTJRxGc&e=cadence.moe%2FikYKbkhGhMERAuPPbsnQzZiX&e=cadence.moe%2FAYPpqXzVJvZdzMQJGjioIQBZ&e=cadence.moe%2FUVuzvpVUhqjiueMxYXJiFEAj&e=cadence.moe%2FlHfmJpzgoNyNtYHdAmBHxXix&e=cadence.moe%2FMtRdXixoKjKKOyHJGWLsWLNU&e=cadence.moe%2FHXfFuougamkURPPMflTJRxGc&e=cadence.moe%2FikYKbkhGhMERAuPPbsnQzZiX&e=cadence.moe%2FAYPpqXzVJvZdzMQJGjioIQBZ&e=cadence.moe%2FUVuzvpVUhqjiueMxYXJiFEAj", {
+ event,
+ mxcDownloader: mockGetAndConvertEmoji
+ })
+ t.equal(event.node.res.statusCode, 200)
+ t.equal(sheet.subarray(0, 90).toString("base64"), "iVBORw0KGgoAAAANSUhEUgAAAYAAAABgCAYAAAAU9KWJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAgAElEQVR4nOx9B3hUVdr/KIpKL2nT0pPpLRNQkdXddV1c")
+})
diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js
index b640d364..63dd3ec5 100644
--- a/src/web/routes/guild-settings.js
+++ b/src/web/routes/guild-settings.js
@@ -74,6 +74,8 @@ as.router.post("/api/autocreate", defineToggle("autocreate", {
as.router.post("/api/url-preview", defineToggle("url_preview"))
+as.router.post("/api/webhook-profile", defineToggle("webhook_profile"))
+
as.router.post("/api/presence", defineToggle("presence", {
after() {
setPresence.guildPresenceSetting.update()
diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js
index 8c2d99db..a5508c4a 100644
--- a/src/web/routes/guild.js
+++ b/src/web/routes/guild.js
@@ -18,7 +18,9 @@ const createSpace = sync.require("../../d2m/actions/create-space")
/** @type {import("../auth")} */
const auth = require("../auth")
/** @type {import("../../discord/utils")} */
-const utils = sync.require("../../discord/utils")
+const dUtils = sync.require("../../discord/utils")
+/** @type {import("../../matrix/utils")} */
+const mxUtils = sync.require("../../matrix/utils")
const {reg} = require("../../matrix/read-registration")
const schema = {
@@ -52,23 +54,40 @@ function getAPI(event) {
const validNonce = new LRUCache({max: 200})
/**
- * Modifies the input, removing items that don't pass the filter. Returns the items that didn't pass.
- * @param {T[]} xs
- * @param {(x: T, i?: number) => any} fn
- * @template T
- * @returns T[]
+ * @param {{type: number, parent_id?: string | null, position?: number}} channel
+ * @param {Map} channels
*/
-function filterTo(xs, fn) {
- /** @type {T[]} */
- const filtered = []
- for (let i = xs.length-1; i >= 0; i--) {
- const x = xs[i]
- if (!fn(x, i)) {
- filtered.unshift(x)
- xs.splice(i, 1)
- }
+function getPosition(channel, channels) {
+ let position = 0
+
+ // Categories always appear below un-categorised channels. Their contents can be ordered.
+ // So categories, and things in them, will have their position multiplied by a big number. The category's big number. The regular position small number sorts within the category.
+ // Categories are size 2000.
+ let foundCategory = channel
+ while (foundCategory.parent_id) {
+ const f = channels.get(foundCategory.parent_id)
+ assert(f)
+ foundCategory = f
}
- return filtered
+ if (foundCategory.type === DiscordTypes.ChannelType.GuildCategory) position = ((foundCategory.position || 0) + 1) * 2000
+
+ // Categories always appear above what they contain.
+ if (channel.type === DiscordTypes.ChannelType.GuildCategory) position -= 0.5
+
+ // Within a category, voice channels are always sorted to the bottom. The text/voice split is size 1000 each.
+ if ([DiscordTypes.ChannelType.GuildVoice, DiscordTypes.ChannelType.GuildStageVoice].includes(channel.type)) position += 1000
+
+ // Channels are manually ordered within the text/voice split.
+ if (typeof channel.position === "number") position += channel.position
+
+ // Threads appear below their channel.
+ if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) {
+ position += 0.5
+ let parent = channels.get(channel.parent_id || "")
+ if (parent && parent["position"]) position += parent["position"]
+ }
+
+ return position
}
/**
@@ -77,43 +96,36 @@ function filterTo(xs, fn) {
* @param {string[]} roles
*/
function getChannelRoomsLinks(guild, rooms, roles) {
- function getPosition(channel) {
- let position = 0
- let looking = channel
- while (looking.parent_id) {
- looking = discord.channels.get(looking.parent_id)
- position = looking.position * 1000
- }
- if (channel.position) position += channel.position
- return position
- }
-
let channelIDs = discord.guildChannelMap.get(guild.id)
assert(channelIDs)
let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {channel_id: channelIDs}).all()
- let linkedChannelsWithDetails = linkedChannels.map(c => ({channel: discord.channels.get(c.channel_id), ...c}))
- let removedUncachedChannels = filterTo(linkedChannelsWithDetails, c => c.channel)
+ let linkedChannelsWithDetails = linkedChannels.map(c => ({
+ // @ts-ignore
+ /** @type {DiscordTypes.APIGuildChannel} */ channel: discord.channels.get(c.channel_id),
+ ...c
+ }))
+ let removedUncachedChannels = dUtils.filterTo(linkedChannelsWithDetails, c => c.channel)
let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id)
- linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel) - getPosition(b.channel))
+ linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel, discord.channels) - getPosition(b.channel, discord.channels))
let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c))
/** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore
let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c))
- let removedWrongTypeChannels = filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type))
- let removedPrivateChannels = filterTo(unlinkedChannels, c => {
- const permissions = utils.getPermissions(roles, guild.roles, botID, c["permission_overwrites"])
- return utils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel)
+ let removedWrongTypeChannels = dUtils.filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type))
+ let removedPrivateChannels = dUtils.filterTo(unlinkedChannels, c => {
+ const permissions = dUtils.getPermissions(guild.id, roles, guild.roles, botID, c["permission_overwrites"])
+ return dUtils.hasSomePermissions(permissions, ["Administrator", "ViewChannel"])
})
- unlinkedChannels.sort((a, b) => getPosition(a) - getPosition(b))
+ unlinkedChannels.sort((a, b) => getPosition(a, discord.channels) - getPosition(b, discord.channels))
let linkedRoomIDs = linkedChannels.map(c => c.room_id)
let unlinkedRooms = [...rooms]
- let removedLinkedRooms = filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id))
- let removedWrongTypeRooms = filterTo(unlinkedRooms, r => !r.room_type)
+ let removedLinkedRooms = dUtils.filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id))
+ let removedWrongTypeRooms = dUtils.filterTo(unlinkedRooms, r => !r.room_type)
// 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
- let removedArchivedThreadRooms = filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/))
+ let removedArchivedThreadRooms = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/))
return {
linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms,
@@ -121,6 +133,20 @@ function getChannelRoomsLinks(guild, rooms, roles) {
}
}
+/**
+ * @param {string} mxid
+ */
+function getInviteTargetSpaces(mxid) {
+ /** @type {{room_id: string, mxid: string, type: string, name: string, topic: string?, avatar: string?}[]} */
+ const spaces =
+ // invited spaces
+ db.prepare("SELECT room_id, invite.mxid, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id WHERE mxid = ? AND space_id IS NULL AND type = 'm.space'").all(mxid)
+ // moderated spaces
+ .concat(db.prepare("SELECT room_id, invite.mxid, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id INNER JOIN member_cache USING (room_id) WHERE member_cache.mxid = ? AND power_level >= 50 AND space_id IS NULL AND type = 'm.space'").all(mxid))
+ const seen = new Set(spaces.map(s => s.room_id))
+ return spaces.filter(s => seen.delete(s.room_id))
+}
+
as.router.get("/guild", defineEventHandler(async event => {
const {guild_id} = await getValidatedQuery(event, schema.guild.parse)
const session = await auth.useSession(event)
@@ -136,7 +162,7 @@ as.router.get("/guild", defineEventHandler(async event => {
// Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow
if (!row.space_id && row.autocreate === 0) {
- const spaces = db.prepare("SELECT room_id, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id WHERE mxid = ? AND space_id IS NULL AND type = 'm.space'").all(session.data.mxid)
+ const spaces = session.data.mxid ? getInviteTargetSpaces(session.data.mxid) : []
return pugSync.render(event, "guild_not_linked.pug", {guild, guild_id, spaces})
}
@@ -228,7 +254,7 @@ as.router.post("/api/invite", defineEventHandler(async event => {
( parsedBody.permissions === "admin" ? 100
: parsedBody.permissions === "moderator" ? 50
: 0)
- if (powerLevel) await api.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel)
+ if (powerLevel) await mxUtils.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel, api)
if (parsedBody.guild_id) {
setResponseHeader(event, "HX-Refresh", true)
@@ -237,3 +263,6 @@ as.router.post("/api/invite", defineEventHandler(async event => {
return sendRedirect(event, "/ok?msg=User has been invited.", 302)
}
}))
+
+module.exports._getPosition = getPosition
+module.exports.getInviteTargetSpaces = getInviteTargetSpaces
diff --git a/src/web/routes/guild.test.js b/src/web/routes/guild.test.js
index ea59173e..aa17548e 100644
--- a/src/web/routes/guild.test.js
+++ b/src/web/routes/guild.test.js
@@ -1,8 +1,10 @@
// @ts-check
+const DiscordTypes = require("discord-api-types/v10")
const tryToCatch = require("try-to-catch")
const {router, test} = require("../../../test/web")
const {MatrixServerError} = require("../../matrix/mreq")
+const {_getPosition} = require("./guild")
let nonce
@@ -89,7 +91,7 @@ test("web guild: unbridged self-service guild shows available spaces", async t =
})
t.has(html, `Data Horde`)
t.has(html, `here is the space topic `)
- t.has(html, `
`)
+ t.has(html, `
`)
t.notMatch(html, /some room<\/strong>/)
t.notMatch(html, /somebody else's space<\/strong>/)
})
@@ -101,12 +103,6 @@ test("web guild: can view bridged guild when logged in with discord", async t =>
managedGuilds: ["112760669178241024"]
},
api: {
- async getStateEvent(roomID, type, key) {
- return {}
- },
- async getMembers(roomID, membership) {
- return {chunk: []}
- },
async getFullHierarchy(roomID) {
return []
}
@@ -121,12 +117,6 @@ test("web guild: can view bridged guild when logged in with matrix", async t =>
mxid: "@cadence:cadence.moe"
},
api: {
- async getStateEvent(roomID, type, key) {
- return {}
- },
- async getMembers(roomID, membership) {
- return {chunk: []}
- },
async getFullHierarchy(roomID) {
return []
}
@@ -190,21 +180,66 @@ test("api invite: can invite with valid nonce", async t => {
api: {
async getStateEvent(roomID, type, key) {
called++
- return {membership: "leave"}
+ if (type === "m.room.member" && key === "@cadence:cadence.moe") {
+ throw new Error("event not found")
+ } else if (type === "m.room.power_levels" && key === "") {
+ return {}
+ }
+ /* c8 ignore next */
+ t.fail(`unexpected getStateEvent call. roomID: ${roomID}, type: ${type}, key: ${key}`)
+ },
+ async getStateEventOuter(roomID, type, key) {
+ called++
+ return {
+ type: "m.room.create",
+ state_key: "",
+ sender: "@_ooye_bot:cadence.moe",
+ event_id: "$create",
+ origin_server_ts: 0,
+ room_id: roomID,
+ content: {
+ room_version: "11"
+ }
+ }
},
async inviteToRoom(roomID, mxidToInvite, mxid) {
+ called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
- called++
},
- async setUserPowerCascade(roomID, mxid, power) {
- t.equal(power, 50) // moderator
+ async *generateFullHierarchy(spaceID) {
called++
+ yield {
+ room_id: "!hierarchy",
+ children_state: [],
+ guest_can_join: false,
+ num_joined_members: 2,
+ }
+ },
+ async sendState(roomID, type, key, content) {
+ called++
+ t.ok(["!hierarchy", "!jjmvBegULiLucuWEHU:cadence.moe"].includes(roomID), `expected room ID to be in hierarchy, but was ${roomID}`)
+ t.equal(type, "m.room.power_levels")
+ t.equal(key, "")
+ t.deepEqual(content, {
+ users: {"@cadence:cadence.moe": 50}
+ })
+ return "$updated"
}
}
})
)
t.notOk(error)
- t.equal(called, 3)
+ /*
+ 1. get membership
+ 2. invite to room
+ set power:
+ 3. generate hierarchy
+ 4-5. calculate powers
+ 6. send state
+ 7-8. calculate powers
+ 9. send state
+ */
+ t.equal(called, 9) // get membership +
})
test("api invite: access denied when nonce has been used", async t => {
@@ -235,21 +270,63 @@ test("api invite: can invite to a moderated guild", async t => {
api: {
async getStateEvent(roomID, type, key) {
called++
- throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "Event not found or something"})
+ if (type === "m.room.member" && key === "@cadence:cadence.moe") {
+ return {membership: "leave"}
+ } else if (type === "m.room.power_levels" && key === "") {
+ return {}
+ }
+ /* c8 ignore next */
+ t.fail(`unexpected getStateEvent call. roomID: ${roomID}, type: ${type}, key: ${key}`)
+ },
+ async getStateEventOuter(roomID, type, key) {
+ called++
+ return {
+ type: "m.room.create",
+ state_key: "",
+ sender: "@_ooye_bot:cadence.moe",
+ event_id: "$create",
+ origin_server_ts: 0,
+ room_id: roomID,
+ content: {
+ room_version: "11"
+ }
+ }
},
async inviteToRoom(roomID, mxidToInvite, mxid) {
+ called++
t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe")
- called++
},
- async setUserPowerCascade(roomID, mxid, power) {
- t.equal(power, 100) // moderator
+ async *generateFullHierarchy(spaceID) {
called++
+ yield {
+ room_id: "!hierarchy",
+ children_state: [],
+ guest_can_join: false,
+ num_joined_members: 2,
+ }
+ yield {
+ room_id: spaceID,
+ children_state: [],
+ guest_can_join: false,
+ num_joined_members: 2,
+ room_type: "m.space"
+ }
+ },
+ async sendState(roomID, type, key, content) {
+ called++
+ t.ok(["!hierarchy", "!jjmvBegULiLucuWEHU:cadence.moe"].includes(roomID), `expected room ID to be in hierarchy, but was ${roomID}`)
+ t.equal(type, "m.room.power_levels")
+ t.equal(key, "")
+ t.deepEqual(content, {
+ users: {"@cadence:cadence.moe": 100}
+ })
+ return "$updated"
}
}
})
)
t.notOk(error)
- t.equal(called, 3)
+ t.equal(called, 9)
})
test("api invite: does not reinvite joined users", async t => {
@@ -275,3 +352,45 @@ test("api invite: does not reinvite joined users", async t => {
t.notOk(error)
t.equal(called, 1)
})
+
+
+test("position sorting: sorts like discord does", t => {
+ const channelsList = [{
+ type: DiscordTypes.ChannelType.GuildText,
+ id: "first",
+ position: 0
+ }, {
+ type: DiscordTypes.ChannelType.PublicThread,
+ id: "thread",
+ parent_id: "first",
+ }, {
+ type: DiscordTypes.ChannelType.GuildText,
+ id: "second",
+ position: 1
+ }, {
+ type: DiscordTypes.ChannelType.GuildVoice,
+ id: "voice",
+ position: 0
+ }, {
+ type: DiscordTypes.ChannelType.GuildCategory,
+ id: "category",
+ position: 0
+ }, {
+ type: DiscordTypes.ChannelType.GuildText,
+ id: "category-first",
+ parent_id: "category",
+ position: 0
+ }, {
+ type: DiscordTypes.ChannelType.GuildText,
+ id: "category-second",
+ parent_id: "category",
+ position: 1
+ }, {
+ type: DiscordTypes.ChannelType.PublicThread,
+ id: "category-second-thread",
+ parent_id: "category-second",
+ }].reverse()
+ const channels = new Map(channelsList.map(c => [c.id, c]))
+ const sortedChannelIDs = [...channels.values()].sort((a, b) => _getPosition(a, channels) - _getPosition(b, channels)).map(c => c.id)
+ t.deepEqual(sortedChannelIDs, ["first", "thread", "second", "voice", "category", "category-first", "category-second", "category-second-thread"])
+})
diff --git a/src/web/routes/info.js b/src/web/routes/info.js
index 0ccdecad..e83bf89f 100644
--- a/src/web/routes/info.js
+++ b/src/web/routes/info.js
@@ -4,8 +4,8 @@ const {z} = require("zod")
const {defineEventHandler, getValidatedQuery, H3Event} = require("h3")
const {as, from, sync, select} = require("../../passthrough")
-/** @type {import("../../m2d/converters/utils")} */
-const mUtils = sync.require("../../m2d/converters/utils")
+/** @type {import("../../matrix/utils")} */
+const mUtils = sync.require("../../matrix/utils")
/**
* @param {H3Event} event
@@ -26,16 +26,28 @@ as.router.get("/api/message", defineEventHandler(async event => {
const api = getAPI(event)
const {message_id} = await getValidatedQuery(event, schema.message.parse)
- const metadatas = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").where({message_id})
- .select("event_id", "event_type", "event_subtype", "part", "reaction_part", "room_id", "source").and("ORDER BY part ASC, reaction_part DESC").all()
+ const metadatas = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index").where({message_id})
+ .select("event_id", "event_type", "event_subtype", "part", "reaction_part", "reference_channel_id", "room_id", "source").and("ORDER BY part ASC, reaction_part DESC").all()
if (metadatas.length === 0) {
return new Response("Message not found", {status: 404, statusText: "Not Found"})
}
+ const current_room_id = select("channel_room", "room_id", {channel_id: metadatas[0].reference_channel_id}).pluck().get()
const events = await Promise.all(metadatas.map(metadata =>
api.getEvent(metadata.room_id, metadata.event_id).then(raw => ({
- metadata: Object.assign({sender: raw.sender}, metadata),
+ metadata: {
+ event_id: metadata.event_id,
+ event_type: metadata.event_type,
+ event_subtype: metadata.event_subtype,
+ part: metadata.part,
+ reaction_part: metadata.reaction_part,
+ channel_id: metadata.reference_channel_id,
+ room_id: metadata.room_id,
+ source: metadata.source,
+ sender: raw.sender,
+ current_room_id: current_room_id
+ },
raw
}))
))
@@ -56,8 +68,7 @@ as.router.get("/api/message", defineEventHandler(async event => {
}
}
if (!matrix_author.displayname) matrix_author.displayname = mxid
- if (matrix_author.avatar_url) matrix_author.avatar_url = mUtils.getPublicUrlForMxc(matrix_author.avatar_url)
- else matrix_author.avatar_url = null
+ matrix_author.avatar_url = mUtils.getPublicUrlForMxc(matrix_author.avatar_url) || null
matrix_author["mxid"] = mxid
}
diff --git a/src/web/routes/info.test.js b/src/web/routes/info.test.js
index 28dac3bb..39b2c00d 100644
--- a/src/web/routes/info.test.js
+++ b/src/web/routes/info.test.js
@@ -57,14 +57,16 @@ test("web info: returns data for a matrix message and profile", async t => {
},
events: [{
metadata: {
- event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk",
- event_subtype: "m.text",
- event_type: "m.room.message",
- part: 0,
- reaction_part: 0,
- room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe",
- sender: "@cadence:cadence.moe",
- source: 0
+ event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk",
+ event_subtype: "m.text",
+ event_type: "m.room.message",
+ part: 0,
+ reaction_part: 0,
+ room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe",
+ channel_id: "176333891320283136",
+ current_room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ source: 0
},
raw
}]
@@ -113,14 +115,16 @@ test("web info: returns data for a matrix message without profile", async t => {
},
events: [{
metadata: {
- event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk",
- event_subtype: "m.text",
- event_type: "m.room.message",
- part: 0,
- reaction_part: 0,
- room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe",
- sender: "@cadence:cadence.moe",
- source: 0
+ event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk",
+ event_subtype: "m.text",
+ event_type: "m.room.message",
+ part: 0,
+ reaction_part: 0,
+ room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe",
+ channel_id: "176333891320283136",
+ current_room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ source: 0
},
raw
}]
@@ -191,14 +195,16 @@ test("web info: returns data for a discord message", async t => {
matrix_author: undefined,
events: [{
metadata: {
- event_id: "$AfrB8hzXkDMvuoWjSZkDdFYomjInWH7jMBPkwQMN8AI",
- event_subtype: "m.text",
- event_type: "m.room.message",
- part: 0,
- reaction_part: 1,
- room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
- sender: "@_ooye_accavish:cadence.moe",
- source: 1
+ event_id: "$AfrB8hzXkDMvuoWjSZkDdFYomjInWH7jMBPkwQMN8AI",
+ event_subtype: "m.text",
+ event_type: "m.room.message",
+ part: 0,
+ reaction_part: 1,
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ channel_id: "112760669178241024",
+ current_room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ sender: "@_ooye_accavish:cadence.moe",
+ source: 1
},
raw: raw1
}, {
@@ -209,6 +215,8 @@ test("web info: returns data for a discord message", async t => {
part: 1,
reaction_part: 0,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ channel_id: "112760669178241024",
+ current_room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
sender: "@_ooye_accavish:cadence.moe",
source: 1
},
diff --git a/src/web/routes/link.js b/src/web/routes/link.js
index 1fa63e1f..43995fcd 100644
--- a/src/web/routes/link.js
+++ b/src/web/routes/link.js
@@ -1,5 +1,6 @@
// @ts-check
+const assert = require("assert").strict
const {z} = require("zod")
const {defineEventHandler, createError, readValidatedBody, setResponseHeader, H3Event} = require("h3")
const Ty = require("../../types")
@@ -8,11 +9,10 @@ const DiscordTypes = require("discord-api-types/v10")
const {discord, db, as, sync, select, from} = require("../../passthrough")
/** @type {import("../auth")} */
const auth = sync.require("../auth")
-/** @type {import("../../matrix/mreq")} */
-const mreq = sync.require("../../matrix/mreq")
-const {reg} = require("../../matrix/read-registration")
-
-const me = `@${reg.sender_localpart}:${reg.ooye.server_name}`
+/** @type {import("../../matrix/utils")}*/
+const utils = sync.require("../../matrix/utils")
+/** @type {import("./guild")}*/
+const guildRoute = sync.require("./guild")
/**
* @param {H3Event} event
@@ -50,33 +50,6 @@ function getSnow(event) {
return event.context.snow || discord.snow
}
-/**
- * @param {H3Event} event
- * @param {string} channel_id
- * @param {string} guild_id
- */
-async function doRoomUnlink(event, channel_id, guild_id) {
- const createRoom = getCreateRoom(event)
-
- // Check that the channel (if it exists) is part of this guild
- /** @type {any} */
- let channel = discord.channels.get(channel_id)
- if (channel) {
- if (!("guild_id" in channel) || channel.guild_id !== guild_id) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not part of guild ${guild_id}`})
- } else {
- // Otherwise, if the channel isn't cached, it must have been deleted.
- // There's no other authentication here - it's okay for anyone to unlink a deleted channel just by knowing its ID.
- channel = {id: channel_id}
- }
-
- // Check channel is currently bridged
- const row = select("channel_room", "channel_id", {channel_id: channel_id}).get()
- if (!row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not currently bridged`})
-
- // Do it
- await createRoom.unbridgeDeletedChannel(channel, guild_id)
-}
-
const schema = {
linkSpace: z.object({
guild_id: z.string(),
@@ -96,6 +69,33 @@ const schema = {
}),
}
+/**
+ * @param {H3Event} event
+ * @param {string} channel_id
+ * @param {string} guild_id
+ */
+async function validateAndUnbridgeChannel(event, channel_id, guild_id) {
+ const createRoom = getCreateRoom(event)
+
+ // Check channel is currently bridged
+ const row = select("channel_room", "channel_id", {channel_id: channel_id}).get()
+ if (!row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not currently bridged`})
+
+ // Check that the channel (if it exists) is part of this guild
+ /** @type {any} */
+ let channel = discord.channels.get(channel_id)
+ if (channel) {
+ if (!("guild_id" in channel) || channel.guild_id !== guild_id) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not part of guild ${guild_id}`})
+ } else {
+ // Otherwise, if the channel isn't cached, it must have been deleted.
+ // There's no other authentication here - it's okay for anyone to unlink a deleted channel just by knowing its ID.
+ channel = {id: channel_id}
+ }
+
+ // Do it
+ await createRoom.unbridgeChannel(channel, guild_id)
+}
+
as.router.post("/api/link-space", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.linkSpace.parse)
const session = await auth.useSession(event)
@@ -109,16 +109,18 @@ as.router.post("/api/link-space", defineEventHandler(async event => {
// Check space ID
if (!session.data.mxid) throw createError({status: 403, message: "Forbidden", data: "Can't link with your Matrix space if you aren't logged in to Matrix"})
const spaceID = parsedBody.space_id
- const inviteType = select("invite", "type", {mxid: session.data.mxid, room_id: spaceID}).pluck().get()
- if (inviteType !== "m.space") throw createError({status: 403, message: "Forbidden", data: "You personally must invite OOYE to that space on Matrix"})
// Check they are not already bridged
const existing = select("guild_space", "guild_id", {}, "WHERE guild_id = ? OR space_id = ?").get(guildID, spaceID)
if (existing) throw createError({status: 400, message: "Bad Request", data: `Guild ID ${guildID} or space ID ${spaceID} are already bridged and cannot be reused`})
- const inviteSender = select("invite", "mxid", {mxid: session.data.mxid, room_id: spaceID}).pluck().get()
- const inviteSenderServer = inviteSender?.match(/:(.*)/)?.[1]
- const via = [inviteSenderServer || ""]
+ // Check space ID is a valid invite target
+ const inviteRow = guildRoute.getInviteTargetSpaces(session.data.mxid).find(s => s.room_id === spaceID)
+ if (!inviteRow) throw createError({status: 403, message: "Forbidden", data: "You personally must invite OOYE to that space on Matrix"})
+
+ const inviteServer = inviteRow.mxid.match(/:(.*)/)?.[1]
+ assert(inviteServer)
+ const via = [inviteServer]
// Check space exists and bridge is joined
try {
@@ -128,17 +130,11 @@ as.router.post("/api/link-space", defineEventHandler(async event => {
}
// Check bridge has PL 100
- /** @type {Ty.Event.M_Power_Levels?} */
- let powerLevelsStateContent = null
- try {
- powerLevelsStateContent = await api.getStateEvent(spaceID, "m.room.power_levels", "")
- } catch (e) {}
- const selfPowerLevel = powerLevelsStateContent?.users?.[me] ?? powerLevelsStateContent?.users_default ?? 0
- if (selfPowerLevel < (powerLevelsStateContent?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix space"})
+ const {powerLevels, powers: {[utils.bot]: selfPowerLevel, [session.data.mxid]: invitingPowerLevel}} = await utils.getEffectivePower(spaceID, [utils.bot, session.data.mxid], 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 space"})
// Check inviting user is a moderator in the space
- const invitingPowerLevel = powerLevelsStateContent?.users?.[session.data.mxid] ?? powerLevelsStateContent?.users_default ?? 0
- if (invitingPowerLevel < (powerLevelsStateContent?.state_default ?? 50)) throw createError({status: 403, message: "Forbidden", data: `You need to be at least power level 50 (moderator) in the target Matrix space to set up OOYE, but you are currently power level ${invitingPowerLevel}.`})
+ if (invitingPowerLevel < (powerLevels?.state_default ?? 50)) throw createError({status: 403, message: "Forbidden", data: `You need to be at least power level 50 (moderator) in the target Matrix space to set up OOYE, but you are currently power level ${invitingPowerLevel}.`})
// Insert database entry
db.transaction(() => {
@@ -209,20 +205,17 @@ as.router.post("/api/link", defineEventHandler(async event => {
}
// Check bridge has PL 100
- const me = `@${reg.sender_localpart}:${reg.ooye.server_name}`
- /** @type {Ty.Event.M_Power_Levels?} */
- let powerLevelsStateContent = null
- try {
- powerLevelsStateContent = await api.getStateEvent(parsedBody.matrix, "m.room.power_levels", "")
- } catch (e) {}
- const selfPowerLevel = powerLevelsStateContent?.users?.[me] ?? powerLevelsStateContent?.users_default ?? 0
- if (selfPowerLevel < (powerLevelsStateContent?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"})
+ 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"})
// Insert database entry, but keep the room's existing properties if they are set
const nick = await api.getStateEvent(parsedBody.matrix, "m.room.name", "").then(content => content.name || null).catch(() => null)
const avatar = await api.getStateEvent(parsedBody.matrix, "m.room.avatar", "").then(content => content.url || null).catch(() => null)
const topic = await api.getStateEvent(parsedBody.matrix, "m.room.topic", "").then(content => content.topic || null).catch(() => null)
- db.prepare("INSERT INTO channel_room (channel_id, room_id, name, guild_id, nick, custom_avatar, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, parsedBody.matrix, channel.name, guildID, nick, avatar, topic)
+ db.transaction(() => {
+ db.prepare("INSERT INTO channel_room (channel_id, room_id, name, guild_id, nick, custom_avatar, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, parsedBody.matrix, channel.name, guildID, nick, avatar, topic)
+ db.prepare("INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) VALUES (?, ?, 0)").run(channel.id, parsedBody.matrix)
+ })()
// Sync room data and space child
await createRoom.syncRoom(parsedBody.discord)
@@ -250,7 +243,7 @@ as.router.post("/api/unlink", defineEventHandler(async event => {
const guild = discord.guilds.get(guild_id)
if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"})
- await doRoomUnlink(event, channel_id, guild_id)
+ await validateAndUnbridgeChannel(event, channel_id, guild_id)
setResponseHeader(event, "HX-Refresh", "true")
return null // 204
@@ -269,38 +262,36 @@ as.router.post("/api/unlink-space", defineEventHandler(async event => {
const guild = discord.guilds.get(guild_id)
if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"})
+ const active = select("guild_active", "guild_id", {guild_id: guild_id}).get()
+ if (!active) {
+ throw createError({status: 400, message: "Bad Request", data: "Discord guild has not been considered for bridging"})
+ }
+
+ // Check if there are Matrix resources
const spaceID = select("guild_space", "space_id", {guild_id: guild_id}).pluck().get()
- if (!spaceID) {
- throw createError({status: 400, message: "Bad Request", data: "Matrix space does not exist or bot has not linked it"})
+ if (spaceID) {
+ // Unlink all rooms
+ const linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all()
+ for (const channel of linkedChannels) {
+ await validateAndUnbridgeChannel(event, channel.channel_id, guild_id)
+ }
+
+ // Verify all rooms were unlinked
+ const remainingLinkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all()
+ if (remainingLinkedChannels.length) {
+ throw createError({status: 500, message: "Internal Server Error", data: "Failed to unlink some rooms. Please try doing it manually, or report a bug. The space will not be unlinked until all rooms are."})
+ }
+
+ // Unlink space
+ await utils.setUserPower(spaceID, utils.bot, 0, api)
+ await api.leaveRoom(spaceID)
+ db.prepare("DELETE FROM guild_space WHERE guild_id = ? AND space_id = ?").run(guild_id, spaceID)
+ db.prepare("DELETE FROM invite WHERE room_id = ?").run(spaceID)
}
- const linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all()
-
- for (const channel of linkedChannels) {
- await doRoomUnlink(event, channel.channel_id, guild_id)
-
- // FIXME: probably fix the underlying issue instead:
- // If not waiting for ~1s, then the room is half unbridged:
- // the resources in the room is not properly cleaned up, meaning that the sim users
- // and the bridge user are not power demoted nor leave the room
- // The entry from the channel_room table is not deleted
- // After that, writing in the discord channel does nothing,
- // and writing in the matrix channel spawns an error for not finding guild_id
- await new Promise(r => setTimeout(r, 5000));
- }
-
- const remainingLinkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {guild_id: guild_id}).all()
- if (remainingLinkedChannels.length !== 0) {
- throw createError({status: 500, message: "Internal Server Error", data: "Some linked room still exists after trying to unlink all of them. Aborting the space unlinking..."})
- }
-
- await api.setUserPower(spaceID, me, 0)
- await api.leaveRoom(spaceID)
-
- db.prepare("DELETE FROM guild_space WHERE guild_id=? AND space_id=?").run(guild_id, spaceID)
- db.prepare("DELETE FROM guild_active WHERE guild_id=?").run(guild_id)
+ // Mark as not considered for bridging
+ db.prepare("DELETE FROM guild_active WHERE guild_id = ?").run(guild_id)
await snow.user.leaveGuild(guild_id)
- db.prepare("DELETE FROM invite WHERE room_id=?").run(spaceID)
setResponseHeader(event, "HX-Redirect", "/")
return null
diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js
index 721808e2..e8473f85 100644
--- a/src/web/routes/link.test.js
+++ b/src/web/routes/link.test.js
@@ -81,63 +81,6 @@ test("web link space: check that OOYE is joined", async t => {
t.equal(called, 1)
})
-test("web link space: check that OOYE has PL 100 (not missing)", async t => {
- let called = 0
- const [error] = await tryToCatch(() => router.test("post", "/api/link-space", {
- sessionData: {
- managedGuilds: ["665289423482519565"],
- mxid: "@cadence:cadence.moe"
- },
- body: {
- space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
- guild_id: "665289423482519565"
- },
- api: {
- async joinRoom(roomID) {
- called++
- return roomID
- },
- async getStateEvent(roomID, type, key) {
- called++
- t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
- t.equal(type, "m.room.power_levels")
- throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "what if I told you that power levels never existed"})
- }
- }
- }))
- t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix space")
- t.equal(called, 2)
-})
-
-test("web link space: check that OOYE has PL 100 (not users_default)", async t => {
- let called = 0
- const [error] = await tryToCatch(() => router.test("post", "/api/link-space", {
- sessionData: {
- managedGuilds: ["665289423482519565"],
- mxid: "@cadence:cadence.moe"
- },
- body: {
- space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
- guild_id: "665289423482519565"
- },
- api: {
- async joinRoom(roomID) {
- called++
- return roomID
- },
- async getStateEvent(roomID, type, key) {
- called++
- t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
- t.equal(type, "m.room.power_levels")
- t.equal(key, "")
- return {}
- }
- }
- }))
- t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix space")
- t.equal(called, 2)
-})
-
test("web link space: check that OOYE has PL 100 (not 50)", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link-space", {
@@ -160,11 +103,28 @@ test("web link space: check that OOYE has PL 100 (not 50)", async t => {
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users: {"@_ooye_bot:cadence.moe": 50}}
+ },
+ async getStateEventOuter(roomID, type, key) {
+ called++
+ t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
+ t.equal(type, "m.room.create")
+ t.equal(key, "")
+ return {
+ type: "m.room.create",
+ state_key: "",
+ sender: "@creator:cadence.moe",
+ room_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
+ event_id: "$create",
+ origin_server_ts: 0,
+ content: {
+ room_version: "11"
+ }
+ }
}
}
}))
t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix space")
- t.equal(called, 2)
+ t.equal(called, 3)
})
test("web link space: check that inviting user has PL 50", async t => {
@@ -188,12 +148,29 @@ test("web link space: check that inviting user has PL 50", async t => {
t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
- return {users: {"@_ooye_bot:cadence.moe": 100}}
+ return {users: {"@_ooye_bot:cadence.moe": 100}, events: {"m.room.tombstone": 150}}
+ },
+ async getStateEventOuter(roomID, type, key) {
+ called++
+ t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
+ t.equal(type, "m.room.create")
+ t.equal(key, "")
+ return {
+ type: "m.room.create",
+ state_key: "",
+ sender: "@creator:cadence.moe",
+ room_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
+ event_id: "$create",
+ origin_server_ts: 0,
+ content: {
+ room_version: "12"
+ }
+ }
}
}
}))
t.equal(error.data, "You need to be at least power level 50 (moderator) in the target Matrix space to set up OOYE, but you are currently power level 0.")
- t.equal(called, 2)
+ t.equal(called, 3)
})
test("web link space: successfully adds entry to database and loads page", async t => {
@@ -217,11 +194,28 @@ test("web link space: successfully adds entry to database and loads page", async
t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
- return {users: {"@_ooye_bot:cadence.moe": 100, "@cadence:cadence.moe": 50}}
+ return {users: {"@cadence:cadence.moe": 50}}
+ },
+ async getStateEventOuter(roomID, type, key) {
+ called++
+ t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
+ t.equal(type, "m.room.create")
+ t.equal(key, "")
+ return {
+ type: "m.room.create",
+ state_key: "",
+ sender: "@_ooye_bot:cadence.moe",
+ room_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
+ event_id: "$create",
+ origin_server_ts: 0,
+ content: {
+ room_version: "12"
+ }
+ }
}
}
})
- t.equal(called, 2)
+ t.equal(called, 3)
// check that the entry was added to the database
t.equal(select("guild_space", "privacy_level", {guild_id: "665289423482519565", space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe"}).pluck().get(), 0)
@@ -441,47 +435,7 @@ test("web link room: check that bridge can join room (uses via for join attempt)
t.equal(called, 2)
})
-test("web link room: check that bridge has PL 100 in target room (event missing)", 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")
- t.equal(type, "m.room.power_levels")
- t.equal(key, "")
- throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "what if I told you there's no such thing as power levels"})
- }
- }
- }))
- t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room")
- t.equal(called, 3)
-})
-
-test("web link room: check that bridge has PL 100 in target room (users default)", async t => {
+test("web link room: check that bridge has PL 100 in target room", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
@@ -514,11 +468,28 @@ test("web link room: check that bridge has PL 100 in target room (users default)
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {users_default: 50}
+ },
+ async getStateEventOuter(roomID, type, key) {
+ called++
+ t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
+ t.equal(type, "m.room.create")
+ t.equal(key, "")
+ return {
+ type: "m.room.create",
+ state_key: "",
+ sender: "@creator:cadence.moe",
+ room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
+ event_id: "$create",
+ origin_server_ts: 0,
+ content: {
+ room_version: "11"
+ }
+ }
}
}
}))
t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room")
- t.equal(called, 3)
+ t.equal(called, 4)
})
test("web link room: successfully calls createRoom", async t => {
@@ -568,6 +539,23 @@ test("web link room: successfully calls createRoom", async t => {
return {}
}
},
+ async getStateEventOuter(roomID, type, key) {
+ called++
+ t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
+ t.equal(type, "m.room.create")
+ t.equal(key, "")
+ return {
+ type: "m.room.create",
+ state_key: "",
+ sender: "@creator:cadence.moe",
+ room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
+ event_id: "$create",
+ origin_server_ts: 0,
+ content: {
+ room_version: "11"
+ }
+ }
+ },
async sendEvent(roomID, type, content) {
called++
t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
@@ -584,7 +572,7 @@ test("web link room: successfully calls createRoom", async t => {
}
}
})
- t.equal(called, 8)
+ t.equal(called, 9)
})
// *****
@@ -625,7 +613,7 @@ test("web unlink room: checks that the channel is part of the guild", async t =>
t.equal(error.data, "Channel ID 112760669178241024 is not part of guild 665289423482519565")
})
-test("web unlink room: successfully calls unbridgeDeletedChannel when the channel does exist", async t => {
+test("web unlink room: successfully calls unbridgeChannel when the channel does exist", async t => {
let called = 0
await router.test("post", "/api/unlink", {
sessionData: {
@@ -636,7 +624,7 @@ test("web unlink room: successfully calls unbridgeDeletedChannel when the channe
guild_id: "665289423482519565"
},
createRoom: {
- async unbridgeDeletedChannel(channel) {
+ async unbridgeChannel(channel) {
called++
t.equal(channel.id, "665310973967597573")
}
@@ -645,7 +633,7 @@ test("web unlink room: successfully calls unbridgeDeletedChannel when the channe
t.equal(called, 1)
})
-test("web unlink room: successfully calls unbridgeDeletedChannel when the channel does not exist", async t => {
+test("web unlink room: successfully calls unbridgeChannel when the channel does not exist", async t => {
let called = 0
await router.test("post", "/api/unlink", {
sessionData: {
@@ -656,7 +644,7 @@ test("web unlink room: successfully calls unbridgeDeletedChannel when the channe
guild_id: "112760669178241024"
},
createRoom: {
- async unbridgeDeletedChannel(channel) {
+ async unbridgeChannel(channel) {
called++
t.equal(channel.id, "489237891895768942")
}
@@ -709,8 +697,8 @@ test("web unlink space: checks that guild exists", async t => {
})
test("web unlink space: checks that a space is linked to the guild before trying to unlink the space", async t => {
- const row = db.prepare("SELECT * FROM guild_space WHERE guild_id = '665289423482519565'").get()
- db.prepare("DELETE FROM guild_space WHERE guild_id = '665289423482519565'").run()
+ db.exec("BEGIN TRANSACTION")
+ db.prepare("DELETE FROM guild_active WHERE guild_id = '665289423482519565'").run()
const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", {
sessionData: {
@@ -720,11 +708,9 @@ test("web unlink space: checks that a space is linked to the guild before trying
guild_id: "665289423482519565"
}
}))
- t.equal(error.data, "Matrix space does not exist or bot has not linked it")
+ t.equal(error.data, "Discord guild has not been considered for bridging")
- db.prepare("INSERT INTO guild_space (guild_id, space_id, privacy_level, presence, url_preview) VALUES (?, ?, ?, ?, ?)").run(row.guild_id, row.space_id, row.privacy_level, row.presence, row.url_preview)
- const new_row = db.prepare("SELECT * FROM guild_space WHERE guild_id = '665289423482519565'").get()
- t.deepEqual(row, new_row)
+ db.exec("ROLLBACK") // ぬ
})
test("web unlink space: correctly abort unlinking if some linked channels remain after trying to unlink them all", async t => {
@@ -738,9 +724,9 @@ test("web unlink space: correctly abort unlinking if some linked channels remain
guild_id: "665289423482519565",
},
createRoom: {
- async unbridgeDeletedChannel(channel, guildID) {
+ async unbridgeChannel(channel, guildID) {
unbridgedChannel = true
- t.equal(channel.id, "665310973967597573")
+ t.ok(["1438284564815548418", "665310973967597573"].includes(channel.id))
t.equal(guildID, "665289423482519565")
// Do not actually delete the link from DB, should trigger error later in check
}
@@ -759,11 +745,11 @@ test("web unlink space: correctly abort unlinking if some linked channels remain
}
}))
- t.equal(error.data, "Some linked room still exists after trying to unlink all of them. Aborting the space unlinking...")
+ t.equal(error.data, "Failed to unlink some rooms. Please try doing it manually, or report a bug. The space will not be unlinked until all rooms are.")
t.equal(unbridgedChannel, true)
})
-test("web unlink space: successfully calls unbridgeDeletedChannel on linked channels in space, self-downgrade power level, leave space, and delete link from DB", async t => {
+test("web unlink space: successfully calls unbridgeChannel on linked channels in space, self-downgrade power level, leave space, and delete link from DB", async t => {
const {reg} = require("../../matrix/read-registration")
const me = `@${reg.sender_localpart}:${reg.ooye.server_name}`
@@ -783,13 +769,21 @@ test("web unlink space: successfully calls unbridgeDeletedChannel on linked chan
guild_id: "665289423482519565",
},
createRoom: {
- async unbridgeDeletedChannel(channel, guildID) {
+ async unbridgeChannel(channel, guildID) {
unbridgedChannel = true
- t.equal(channel.id, "665310973967597573")
+ t.ok(["1438284564815548418", "665310973967597573"].includes(channel.id))
t.equal(guildID, "665289423482519565")
// In order to not simulate channel deletion and not trigger the post unlink channels, pre-unlink space check
- db.prepare("DELETE FROM channel_room WHERE guild_id = '665289423482519565' AND channel_id = '665310973967597573'").run()
+ db.prepare("DELETE FROM channel_room WHERE channel_id = ?").run(channel.id)
+ }
+ },
+ snow: {
+ user: {
+ // @ts-ignore - snowtransfer or discord-api-types broken, 204 No Content should be mapped to void but is actually mapped to never
+ async leaveGuild(guildID) {
+ t.equal(guildID, "665289423482519565")
+ }
}
},
api: {
@@ -804,11 +798,35 @@ test("web unlink space: successfully calls unbridgeDeletedChannel on linked chan
/* c8 ignore next */
},
- async setUserPower(spaceID, targetUser, powerLevel) {
+ async getStateEvent(roomID, type, key) { // getting power levels from space to apply to room
+ t.equal(type, "m.room.power_levels")
+ t.equal(key, "")
+ return {users: {"@_ooye_bot:cadence.moe": 100, "@example:matrix.org": 50}, events: {"m.room.tombstone": 100}}
+ },
+
+ async getStateEventOuter(roomID, type, key) {
+ t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
+ t.equal(type, "m.room.create")
+ t.equal(key, "")
+ return {
+ type: "m.room.create",
+ state_key: "",
+ sender: "@_ooye_bot:cadence.moe",
+ room_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
+ event_id: "$create",
+ origin_server_ts: 0,
+ content: {
+ room_version: "11"
+ }
+ }
+ },
+
+ async sendState(roomID, type, key, content) {
downgradedPowerLevel = true
- t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
- t.equal(targetUser, me)
- t.equal(powerLevel, 0)
+ t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
+ t.equal(type, "m.room.power_levels")
+ t.notOk(me in content.users, `got ${JSON.stringify(content)} but expected bot user to not be present`)
+ return ""
},
async leaveRoom(spaceID) {
diff --git a/src/web/routes/log-in-with-matrix.js b/src/web/routes/log-in-with-matrix.js
index 574c312f..d36d8fa4 100644
--- a/src/web/routes/log-in-with-matrix.js
+++ b/src/web/routes/log-in-with-matrix.js
@@ -79,30 +79,7 @@ as.router.post("/api/log-in-with-matrix", defineEventHandler(async event => {
}
}
- // Check if we have an existing DM
- let roomID = select("direct", "room_id", {mxid}).pluck().get()
- if (roomID) {
- // Check that the person is/still in the room
- try {
- var member = await api.getStateEvent(roomID, "m.room.member", mxid)
- } catch (e) {}
-
- // Invite them back to the room if needed
- if (!member || member.membership === "leave") {
- await api.inviteToRoom(roomID, mxid)
- }
- }
-
- // No existing DM, create a new room and invite
- else {
- roomID = await api.createRoom({
- invite: [mxid],
- is_direct: true,
- preset: "trusted_private_chat"
- })
- // Store the newly created room in account data (Matrix doesn't do this for us automatically, sigh...)
- db.prepare("REPLACE INTO direct (mxid, room_id) VALUES (?, ?)").run(mxid, roomID)
- }
+ const roomID = await api.usePrivateChat(mxid)
const token = randomUUID()
diff --git a/src/web/routes/log-in-with-matrix.test.js b/src/web/routes/log-in-with-matrix.test.js
index bc9c7e02..830556e3 100644
--- a/src/web/routes/log-in-with-matrix.test.js
+++ b/src/web/routes/log-in-with-matrix.test.js
@@ -34,23 +34,25 @@ test("log in with matrix: checks if mxid domain format looks valid", async t =>
t.match(error.data.fieldErrors.mxid, /must match pattern/)
})
-test("log in with matrix: sends message when there is no existing dm room", async t => {
+test("log in with matrix: sends message to log in", async t => {
const event = {}
let called = 0
await router.test("post", "/api/log-in-with-matrix", {
body: {
- mxid: "@cadence:cadence.moe"
+ mxid: "@cadence:cadence.moe",
+ next: "https://bridge.cadence.moe/guild?guild_id=123"
},
api: {
- async createRoom() {
+ async usePrivateChat(mxid) {
called++
+ t.equal(mxid, "@cadence:cadence.moe")
return "!created:cadence.moe"
},
async sendEvent(roomID, type, content) {
called++
t.equal(roomID, "!created:cadence.moe")
t.equal(type, "m.room.message")
- token = content.body.match(/log-in-with-matrix\?token=([a-f0-9-]+)/)[1]
+ token = content.body.match(/log-in-with-matrix\?token=([a-f0-9-]+)&next=/)[1]
t.ok(token, "log in token not issued")
return ""
}
@@ -72,65 +74,6 @@ test("log in with matrix: does not send another message when a log in is in prog
t.match(event.node.res.getHeader("location"), /We already sent you a link on Matrix/)
})
-test("log in with matrix: reuses room from direct", async t => {
- const event = {}
- let called = 0
- await router.test("post", "/api/log-in-with-matrix", {
- body: {
- mxid: "@user1:example.org"
- },
- api: {
- async getStateEvent(roomID, type, key) {
- called++
- t.equal(roomID, "!existing:cadence.moe")
- t.equal(type, "m.room.member")
- t.equal(key, "@user1:example.org")
- return {membership: "join"}
- },
- async sendEvent(roomID) {
- called++
- t.equal(roomID, "!existing:cadence.moe")
- return ""
- }
- },
- event
- })
- t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/)
- t.equal(called, 2)
-})
-
-test("log in with matrix: reuses room from direct, reinviting if user has left", async t => {
- const event = {}
- let called = 0
- await router.test("post", "/api/log-in-with-matrix", {
- body: {
- mxid: "@user2:example.org"
- },
- api: {
- async getStateEvent(roomID, type, key) {
- called++
- t.equal(roomID, "!existing:cadence.moe")
- t.equal(type, "m.room.member")
- t.equal(key, "@user2:example.org")
- throw new MatrixServerError({errcode: "M_NOT_FOUND"})
- },
- async inviteToRoom(roomID, mxid) {
- called++
- t.equal(roomID, "!existing:cadence.moe")
- t.equal(mxid, "@user2:example.org")
- },
- async sendEvent(roomID) {
- called++
- t.equal(roomID, "!existing:cadence.moe")
- return ""
- }
- },
- event
- })
- t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/)
- t.equal(called, 3)
-})
-
// ***** third request *****
diff --git a/src/web/routes/oauth.js b/src/web/routes/oauth.js
index 80765d6a..f4bb61f5 100644
--- a/src/web/routes/oauth.js
+++ b/src/web/routes/oauth.js
@@ -2,13 +2,13 @@
const {z} = require("zod")
const {randomUUID} = require("crypto")
-const {defineEventHandler, getValidatedQuery, sendRedirect, createError} = require("h3")
+const {defineEventHandler, getValidatedQuery, sendRedirect, createError, H3Event} = require("h3")
const {SnowTransfer, tokenless} = require("snowtransfer")
const DiscordTypes = require("discord-api-types/v10")
const getRelativePath = require("get-relative-path")
-const {discord, as, db, sync} = require("../../passthrough")
-const {id} = require("../../../addbot")
+const {as, db, sync} = require("../../passthrough")
+const {id, permissions} = require("../../../addbot")
/** @type {import("../auth")} */
const auth = sync.require("../auth")
const {reg} = require("../../matrix/read-registration")
@@ -33,6 +33,24 @@ const schema = {
})
}
+/**
+ * @param {H3Event} event
+ * @returns {(string) => {user: {getGuilds: () => Promise}}}
+ */
+function getClient(event) {
+ /* c8 ignore next */
+ return event.context.getClient || (accessToken => new SnowTransfer(`Bearer ${accessToken}`))
+}
+
+/**
+ * @param {H3Event} event
+ * @returns {typeof tokenless.getOauth2Token}
+ */
+function getOauth2Token(event) {
+ /* c8 ignore next */
+ return event.context.getOauth2Token || tokenless.getOauth2Token
+}
+
as.router.get("/oauth", defineEventHandler(async event => {
const session = await auth.useSession(event)
let scope = "guilds"
@@ -51,7 +69,7 @@ as.router.get("/oauth", defineEventHandler(async event => {
async function tryAgain() {
const newState = randomUUID()
await session.update({state: newState})
- return sendRedirect(event, `https://discord.com/oauth2/authorize?client_id=${id}&scope=${scope}&permissions=1610883072&response_type=code&redirect_uri=${redirect_uri}&state=${newState}`)
+ return sendRedirect(event, `https://discord.com/oauth2/authorize?client_id=${id}&scope=${scope}&permissions=${permissions}&response_type=code&redirect_uri=${redirect_uri}&state=${newState}`)
}
const parsedQuery = await getValidatedQuery(event, schema.code.safeParse)
@@ -61,21 +79,15 @@ as.router.get("/oauth", defineEventHandler(async event => {
if (!savedState) throw createError({status: 400, message: "Missing state", data: "Missing saved state parameter. Please try again, and make sure you have cookies enabled."})
if (savedState != parsedQuery.data.state) return tryAgain()
- const oauthResult = await tokenless.getOauth2Token(id, redirect_uri, reg.ooye.discord_client_secret, parsedQuery.data.code)
- const parsedToken = schema.token.safeParse(oauthResult)
- if (!parsedToken.success) {
- throw createError({status: 502, message: "Invalid token response", data: `Discord completed OAuth, but returned this instead of an OAuth access token: ${JSON.stringify(oauthResult)}`})
- }
+ const oauthResult = await getOauth2Token(event)(id, redirect_uri, reg.ooye.discord_client_secret, parsedQuery.data.code)
+ const parsedToken = schema.token.parse(oauthResult)
- const userID = Buffer.from(parsedToken.data.access_token.split(".")[0], "base64").toString()
- const client = new SnowTransfer(`Bearer ${parsedToken.data.access_token}`)
- try {
- const guilds = await client.user.getGuilds()
- var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id)
- await session.update({managedGuilds, userID, state: undefined})
- } catch (e) {
- throw createError({status: 502, message: "API call failed", data: e.message})
- }
+ const userID = Buffer.from(parsedToken.access_token.split(".")[0], "base64").toString()
+ const client = getClient(event)(parsedToken.access_token)
+
+ const guilds = await client.user.getGuilds()
+ var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id)
+ await session.update({managedGuilds, userID, state: undefined})
// Set auto-create for the guild
// @ts-ignore
diff --git a/src/web/routes/oauth.test.js b/src/web/routes/oauth.test.js
new file mode 100644
index 00000000..2f3a791e
--- /dev/null
+++ b/src/web/routes/oauth.test.js
@@ -0,0 +1,121 @@
+// @ts-check
+
+const DiscordTypes = require("discord-api-types/v10")
+const tryToCatch = require("try-to-catch")
+const assert = require("assert/strict")
+const {router, test} = require("../../../test/web")
+
+test("web oauth: redirects to Discord on first visit (add easy)", async t => {
+ let event = {}
+ await router.test("get", "/oauth?action=add", {
+ event,
+ sessionData: {
+ password: "password123"
+ }
+ })
+ t.equal(event.node.res.statusCode, 302)
+ t.match(event.node.res.getHeader("location"), /^https:\/\/discord.com\/oauth2\/authorize\?client_id=684280192553844747&scope=bot\+guilds&permissions=2251801424568320&response_type=code&redirect_uri=https:\/\/bridge\.example\.org\/oauth&state=/)
+})
+
+test("web oauth: redirects to Discord on first visit (add self service)", async t => {
+ let event = {}
+ await router.test("get", "/oauth?action=add-self-service", {
+ event,
+ sessionData: {
+ password: "password123"
+ }
+ })
+ t.equal(event.node.res.statusCode, 302)
+ t.match(event.node.res.getHeader("location"), /^https:\/\/discord.com\/oauth2\/authorize\?client_id=684280192553844747&scope=bot\+guilds&permissions=2251801424568320&response_type=code&redirect_uri=https:\/\/bridge\.example\.org\/oauth&state=/)
+})
+
+test("web oauth: advises user about cookies if state is missing", async t => {
+ let event = {}
+ const [e] = await tryToCatch(() => router.test("get", "/oauth?state=693551d5-47c5-49e2-a433-3600abe3c15c&code=DISCORD_CODE&guild_id=9", {
+ event
+ }))
+ t.equal(e.message, "Missing state")
+})
+
+test("web oauth: redirects to Discord again if state doesn't match", async t => {
+ let event = {}
+ await router.test("get", "/oauth?state=693551d5-47c5-49e2-a433-3600abe3c15c&code=DISCORD_CODE", {
+ event,
+ sessionData: {
+ state: "438aa253-1311-4483-9aa2-c251e29e72c9",
+ password: "password123"
+ }
+ })
+ t.equal(event.node.res.statusCode, 302)
+ t.match(event.node.res.getHeader("location"), /^https:\/\/discord\.com\/oauth2\/authorize/)
+})
+
+test("web oauth: uses returned state, logs in", async t => {
+ let event = {}
+ await router.test("get", "/oauth?state=693551d5-47c5-49e2-a433-3600abe3c15c&code=DISCORD_CODE", {
+ event,
+ sessionData: {
+ state: "693551d5-47c5-49e2-a433-3600abe3c15c",
+ selfService: false,
+ password: "password123"
+ },
+ getOauth2Token() {
+ return {
+ token_type: "Bearer",
+ access_token: "6qrZcUqja7812RVdnEKjpzOL4CvHBFG",
+ expires_in: 604800,
+ refresh_token: "D43f5y0ahjqew82jZ4NViEr2YafMKhue",
+ scope: "bot+guilds"
+ }
+ },
+ getClient(accessToken) {
+ return {
+ user: {
+ async getGuilds() {
+ return [{
+ id: "9",
+ permissions: DiscordTypes.PermissionFlagsBits.ManageGuild
+ }]
+ }
+ }
+ }
+ }
+ })
+ t.equal(event.node.res.statusCode, 302)
+ t.equal(event.node.res.getHeader("location"), "./")
+})
+
+test("web oauth: uses returned state, adds managed guild", async t => {
+ let event = {}
+ await router.test("get", "/oauth?state=693551d5-47c5-49e2-a433-3600abe3c15c&code=DISCORD_CODE&guild_id=9", {
+ event,
+ sessionData: {
+ state: "693551d5-47c5-49e2-a433-3600abe3c15c",
+ selfService: false,
+ password: "password123"
+ },
+ getOauth2Token() {
+ return {
+ token_type: "Bearer",
+ access_token: "6qrZcUqja7812RVdnEKjpzOL4CvHBFG",
+ expires_in: 604800,
+ refresh_token: "D43f5y0ahjqew82jZ4NViEr2YafMKhue",
+ scope: "bot+guilds"
+ }
+ },
+ getClient(accessToken) {
+ return {
+ user: {
+ async getGuilds() {
+ return [{
+ id: "9",
+ permissions: DiscordTypes.PermissionFlagsBits.ManageGuild
+ }]
+ }
+ }
+ }
+ }
+ })
+ t.equal(event.node.res.statusCode, 302)
+ t.equal(event.node.res.getHeader("location"), "guild?guild_id=9")
+})
diff --git a/src/web/routes/password.test.js b/src/web/routes/password.test.js
new file mode 100644
index 00000000..aa60bd3a
--- /dev/null
+++ b/src/web/routes/password.test.js
@@ -0,0 +1,16 @@
+// @ts-check
+
+const tryToCatch = require("try-to-catch")
+const {test} = require("supertape")
+const {router} = require("../../../test/web")
+
+test("web password: stores password", async t => {
+ const event = {}
+ await router.test("post", "/api/password", {
+ body: {
+ password: "password123"
+ },
+ event
+ })
+ t.equal(event.node.res.statusCode, 302)
+})
diff --git a/src/web/server.js b/src/web/server.js
index 7c8ed3e4..dc13cf0d 100644
--- a/src/web/server.js
+++ b/src/web/server.js
@@ -4,36 +4,23 @@ const assert = require("assert")
const fs = require("fs")
const {join} = require("path")
const h3 = require("h3")
-const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHeader, handleCacheHeaders} = h3
+const mimeTypes = require("mime-types")
+const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHeader, handleCacheHeaders, serveStatic} = h3
const icons = require("@stackoverflow/stacks-icons")
const DiscordTypes = require("discord-api-types/v10")
const dUtils = require("../discord/utils")
const reg = require("../matrix/read-registration")
-const {sync, discord, as, select} = require("../passthrough")
+const {sync, discord, as, select, from} = require("../passthrough")
/** @type {import("./pug-sync")} */
const pugSync = sync.require("./pug-sync")
-/** @type {import("../m2d/converters/utils")} */
-const mUtils = sync.require("../m2d/converters/utils")
+/** @type {import("../matrix/utils")} */
+const mUtils = sync.require("../matrix/utils")
const {id} = require("../../addbot")
// Pug
-pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, mUtils, icons, reg: reg.reg})
-pugSync.createRoute(as.router, "/", "home.pug")
-pugSync.createRoute(as.router, "/ok", "ok.pug")
-
-// Routes
-
-sync.require("./routes/download-matrix")
-sync.require("./routes/download-discord")
-sync.require("./routes/guild-settings")
-sync.require("./routes/guild")
-sync.require("./routes/info")
-sync.require("./routes/link")
-sync.require("./routes/log-in-with-matrix")
-sync.require("./routes/oauth")
-sync.require("./routes/password")
+pugSync.addGlobals({id, h3, discord, select, from, DiscordTypes, dUtils, mUtils, icons, reg: reg.reg})
// Files
@@ -65,7 +52,79 @@ as.router.get("/static/htmx.js", defineEventHandler({
}
}))
-as.router.get("/icon.png", defineEventHandler(event => {
+as.router.get("/download/file/poll-star-avatar.png", defineEventHandler(event => {
+ handleCacheHeaders(event, {maxAge: 86400})
+ return fs.promises.readFile(join(__dirname, "../../docs/img/poll-star-avatar.png"))
+}))
+
+// Custom files
+
+const publicDir = "custom-webroot"
+
+/**
+ * @param {h3.H3Event} event
+ * @param {boolean} fallthrough
+ */
+function tryStatic(event, fallthrough) {
+ return serveStatic(event, {
+ indexNames: ["/index.html", "/index.pug"],
+ fallthrough,
+ getMeta: async id => {
+ // Check
+ const stats = await fs.promises.stat(join(publicDir, id)).catch(() => {});
+ if (!stats || !stats.isFile()) {
+ return
+ }
+ // Pug
+ if (id.match(/\.pug$/)) {
+ defaultContentType(event, "text/html; charset=utf-8")
+ return {}
+ }
+ // Everything else
+ else {
+ const mime = mimeTypes.lookup(id)
+ if (typeof mime === "string") defaultContentType(event, mime)
+ return {
+ size: stats.size
+ }
+ }
+ },
+ getContents: id => {
+ if (id.match(/\.pug$/)) {
+ const path = join(publicDir, id)
+ return pugSync.renderPath(event, path, {})
+ } else {
+ return fs.promises.readFile(join(publicDir, id))
+ }
+ }
+ })
+}
+
+as.router.get("/**", defineEventHandler(event => {
+ return tryStatic(event, false)
+}))
+
+as.router.get("/", defineEventHandler(async event => {
+ return (await tryStatic(event, true)) || pugSync.render(event, "home.pug", {})
+}))
+
+as.router.get("/icon.png", defineEventHandler(async event => {
+ const s = await tryStatic(event, true)
+ if (s) return s
handleCacheHeaders(event, {maxAge: 86400})
return fs.promises.readFile(join(__dirname, "../../docs/img/icon.png"))
}))
+
+// Routes
+
+pugSync.createRoute(as.router, "/ok", "ok.pug")
+
+sync.require("./routes/download-matrix")
+sync.require("./routes/download-discord")
+sync.require("./routes/guild-settings")
+sync.require("./routes/guild")
+sync.require("./routes/info")
+sync.require("./routes/link")
+sync.require("./routes/log-in-with-matrix")
+sync.require("./routes/oauth")
+sync.require("./routes/password")
diff --git a/start.js b/start.js
index ca6212ba..39e8ea09 100755
--- a/start.js
+++ b/start.js
@@ -36,5 +36,9 @@ sync.require("./src/m2d/event-dispatcher")
sync.require("./src/web/server")
await power.applyPower()
+ discord.cloud.once("ready", () => {
+ as.listen()
+ })
+
require("./src/stdin")
})()
diff --git a/test/addbot.test.js b/test/addbot.test.js
index 17c6dda2..41300516 100644
--- a/test/addbot.test.js
+++ b/test/addbot.test.js
@@ -4,5 +4,5 @@ const {addbot} = require("../addbot")
const {test} = require("supertape")
test("addbot: returns message and invite link", t => {
- t.equal(addbot(), `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=684280192553844747&scope=bot&permissions=1610883072 `)
+ t.equal(addbot(), `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=684280192553844747&scope=bot&permissions=2251801424568320 `)
})
diff --git a/test/data.js b/test/data.js
index e64b9c2f..6a53cb01 100644
--- a/test/data.js
+++ b/test/data.js
@@ -101,6 +101,7 @@ module.exports = {
},
room: {
general: {
+ "m.room.create/": {additional_creators: ["@test_auto_invite:example.org"]},
"m.room.name/": {name: "main"},
"m.room.topic/": {topic: "#collective-unconscious | https://docs.google.com/document/d/blah/edit | I spread, pipe, and whip because it is my will. :headstone:\n\nChannel ID: 112760669178241024\nGuild ID: 112760669178241024"},
"m.room.guest_access/": {guest_access: "can_join"},
@@ -126,13 +127,12 @@ module.exports = {
"m.room.redaction": 0
},
users: {
- "@test_auto_invite:example.org": 100
+ "@test_auto_invite:example.org": 150
},
notifications: {
room: 0
}
},
- "chat.schildi.hide_ui/read_receipts": {},
"uk.half-shot.bridge/moe.cadence.ooye://discord/112760669178241024/112760669178241024": {
bridgebot: "@_ooye_bot:cadence.moe",
protocol: {
@@ -180,6 +180,39 @@ module.exports = {
afk_timeout: 300,
id: "112760669178241024",
icon: "a_f83622e09ead74f0c5c527fe241f8f8c",
+ /** @type {DiscordTypes.APIGuildMember[]} */ // @ts-ignore
+ members: [{
+ user: {
+ username: 'Matrix Bridge',
+ public_flags: 0,
+ primary_guild: null,
+ id: '684280192553844747',
+ global_name: null,
+ display_name_styles: null,
+ display_name: null,
+ discriminator: '5728',
+ collectibles: null,
+ bot: true,
+ avatar_decoration_data: null,
+ avatar: '48ae3c24f2a6ec5c60c41bdabd904018'
+ },
+ roles: [
+ '703457691342995528',
+ '289671295359254529',
+ '1040735082610167858',
+ '114526764860047367'
+ ],
+ premium_since: null,
+ pending: false,
+ nick: 'Mother',
+ mute: false,
+ joined_at: '2020-04-25T04:09:43.253000+00:00',
+ flags: 0,
+ deaf: false,
+ communication_disabled_until: null,
+ banner: null,
+ avatar: null
+ }],
emojis: [
{
roles: [],
@@ -206,7 +239,7 @@ module.exports = {
unicode_emoji: null,
tags: {},
position: 0,
- permissions: '559623605575360',
+ permissions: '1122573558996672',
name: '@everyone',
mentionable: false,
managed: false,
@@ -1223,12 +1256,14 @@ module.exports = {
}
},
pins: {
- faked: [
- {id: "1126786462646550579"},
- {id: "1141501302736695316"},
- {id: "1106366167788044450"},
- {id: "1115688611186193400"}
- ]
+ faked: {
+ items: [
+ {message: {id: "1126786462646550579"}},
+ {message: {id: "1141501302736695316"}},
+ {message: {id: "1106366167788044450"}},
+ {message: {id: "1115688611186193400"}}
+ ]
+ }
},
message: {
// Display order is text content, attachments, then stickers
@@ -2657,6 +2692,47 @@ module.exports = {
flags: 0,
components: []
},
+ large_file_from_matrix: {
+ type: 0,
+ content: "",
+ attachments: [
+ {
+ id: "1439351589474140290",
+ filename: "image.png",
+ size: 5112701,
+ url: "https://cdn.discordapp.com/attachments/1438284564815548418/1439351589474140290/image.png?ex=691cd720&is=691b85a0&hm=671d32324ce17acb9708057f9a532a3184d02343747b32b2ad8d330a277d8f65&",
+ proxy_url: "https://media.discordapp.net/attachments/1438284564815548418/1439351589474140290/image.png?ex=691cd720&is=691b85a0&hm=671d32324ce17acb9708057f9a532a3184d02343747b32b2ad8d330a277d8f65&",
+ width: 1930,
+ height: 2522,
+ content_type: "image/png",
+ flags: 16,
+ content_scan_version: 2,
+ placeholder: "ZhgKDQJ6pIp2B5hmhndoZ0lgiwTJ",
+ placeholder_version: 1,
+ spoiler: false
+ }
+ ],
+ embeds: [],
+ timestamp: new Date().toISOString(),
+ edited_timestamp: null,
+ flags: 0,
+ components: [],
+ id: "1439351590262800565",
+ channel_id: "1438284564815548418",
+ author: {
+ id: "1438286167706701958",
+ username: "cibo",
+ discriminator: "0000",
+ avatar: "9c9e1d63ce093e76b9cdb99328c91201",
+ bot: true
+ },
+ pinned: false,
+ mentions: [],
+ mention_roles: [],
+ mention_everyone: false,
+ tts: false,
+ webhook_id: "1438286167706701958"
+ },
simple_reply_to_reply_in_thread: {
type: 19,
tts: false,
@@ -3143,6 +3219,37 @@ module.exports = {
flags: 0,
components: []
},
+ emojihax: {
+ id: "1126733830494093453",
+ type: 0,
+ content: "I only violate the don't modify our console part of terms of service [troll~1](https://cdn.discordapp.com/emojis/1254940125948022915.webp?size=48&name=troll%7E1&lossless=true)",
+ channel_id: "112760669178241024",
+ author: {
+ id: "111604486476181504",
+ username: "kyuugryphon",
+ avatar: "e4ce31267ca524d19be80e684d4cafa1",
+ discriminator: "0",
+ public_flags: 0,
+ flags: 0,
+ banner: null,
+ accent_color: null,
+ global_name: "KyuuGryphon",
+ avatar_decoration: null,
+ display_name: "KyuuGryphon",
+ banner_color: null
+ },
+ attachments: [],
+ embeds: [],
+ mentions: [],
+ mention_roles: [],
+ pinned: false,
+ mention_everyone: false,
+ tts: false,
+ timestamp: "2023-07-07T04:37:58.892000+00:00",
+ edited_timestamp: null,
+ flags: 0,
+ components: []
+ },
emoji_triple_long_name: {
id: "1156394116540805170",
type: 0,
@@ -3517,7 +3624,233 @@ module.exports = {
},
attachments: [],
guild_id: "286888431945252874"
- }
+ },
+ poll_single_choice: {
+ type: 0,
+ content: "",
+ mentions: [],
+ mention_roles: [],
+ attachments: [],
+ embeds: [],
+ timestamp: "2025-02-15T23:19:04.127000+00:00",
+ edited_timestamp: null,
+ flags: 0,
+ components: [],
+ id: "1340462414176718889",
+ channel_id: "1340048919589158986",
+ author: {
+ id: "307894326028140546",
+ username: "ellienyaa",
+ avatar: "f98417a0a0b4aecc7d7667bece353b7e",
+ discriminator: "0",
+ public_flags: 128,
+ flags: 128,
+ banner: null,
+ accent_color: null,
+ global_name: "unambiguously boring username",
+ avatar_decoration_data: null,
+ banner_color: null,
+ clan: null,
+ primary_guild: null
+ },
+ pinned: false,
+ mention_everyone: false,
+ tts: false,
+ position: 0,
+ poll: {
+ question: {
+ text: "only one answer allowed!"
+ },
+ answers: [
+ {
+ answer_id: 1,
+ poll_media: {
+ text: "answer one",
+ emoji: {
+ id: null,
+ name: "\ud83d\udc4d"
+ }
+ }
+ },
+ {
+ answer_id: 2,
+ poll_media: {
+ text: "answer two",
+ emoji: {
+ id: null,
+ name: "\ud83d\udc4e"
+ }
+ }
+ },
+ {
+ answer_id: 3,
+ poll_media: {
+ text: "answer three"
+ }
+ }
+ ],
+ expiry: "2025-02-16T23:19:04.122364+00:00",
+ allow_multiselect: false,
+ layout_type: 1,
+ results: {
+ answer_counts: [],
+ is_finalized: false
+ }
+ }
+ },
+ poll_multiple_choice: {
+ type: 0,
+ content: "",
+ mentions: [],
+ mention_roles: [],
+ attachments: [],
+ embeds: [],
+ timestamp: "2025-02-16T00:47:12.310000+00:00",
+ edited_timestamp: null,
+ flags: 0,
+ components: [],
+ id: "1340484594423562300",
+ channel_id: "1340048919589158986",
+ author: {
+ id: "307894326028140546",
+ username: "ellienyaa",
+ avatar: "f98417a0a0b4aecc7d7667bece353b7e",
+ discriminator: "0",
+ public_flags: 128,
+ flags: 128,
+ banner: null,
+ accent_color: null,
+ global_name: "unambiguously boring username",
+ avatar_decoration_data: null,
+ banner_color: null,
+ clan: null,
+ primary_guild: null
+ },
+ pinned: false,
+ mention_everyone: false,
+ tts: false,
+ position: 0,
+ poll: {
+ question: {
+ text: "more than one answer allowed"
+ },
+ answers: [
+ {
+ answer_id: 1,
+ poll_media: {
+ text: "no",
+ emoji: {
+ id: null,
+ name: "😭"
+ }
+ }
+ },
+ {
+ answer_id: 2,
+ poll_media: {
+ text: "oh no",
+ emoji: {
+ id: "891723675261366292",
+ name: "this"
+ }
+ }
+ },
+ {
+ answer_id: 3,
+ poll_media: {
+ text: "oh noooooo",
+ emoji: {
+ id: "964520120682680350",
+ name: "disapprove"
+ }
+ }
+ }
+ ],
+ expiry: "2025-02-17T00:47:12.307985+00:00",
+ allow_multiselect: true,
+ layout_type: 1,
+ results: {
+ answer_counts: [],
+ is_finalized: false
+ }
+ }
+ },
+ poll_close: {
+ type: 46,
+ content: "",
+ mentions: [
+ {
+ id: "307894326028140546",
+ username: "ellienyaa",
+ avatar: "f98417a0a0b4aecc7d7667bece353b7e",
+ discriminator: "0",
+ public_flags: 128,
+ flags: 128,
+ banner: null,
+ accent_color: null,
+ global_name: "unambiguously boring username",
+ avatar_decoration_data: null,
+ banner_color: null,
+ clan: null,
+ primary_guild: null
+ }
+ ],
+ mention_roles: [],
+ attachments: [],
+ embeds: [
+ {
+ type: "poll_result",
+ fields: [
+ {
+ name: "poll_question_text",
+ value: "test poll that's being closed",
+ inline: false
+ },
+ {
+ name: "victor_answer_votes",
+ value: "0",
+ inline: false
+ },
+ {
+ name: "total_votes",
+ value: "0",
+ inline: false
+ }
+ ],
+ content_scan_version: 0
+ }
+ ],
+ timestamp: "2025-02-20T23:07:12.178000+00:00",
+ edited_timestamp: null,
+ flags: 0,
+ components: [],
+ id: "1342271367374049351",
+ channel_id: "1340048919589158986",
+ author: {
+ id: "307894326028140546",
+ username: "ellienyaa",
+ avatar: "f98417a0a0b4aecc7d7667bece353b7e",
+ discriminator: "0",
+ public_flags: 128,
+ flags: 128,
+ banner: null,
+ accent_color: null,
+ global_name: "unambiguously boring username",
+ avatar_decoration_data: null,
+ banner_color: null,
+ clan: null,
+ primary_guild: null
+ },
+ pinned: false,
+ mention_everyone: false,
+ tts: false,
+ message_reference: {
+ type: 0,
+ channel_id: "1340048919589158986",
+ message_id: "1342271353990021206"
+ },
+ position: 0
+ }
},
pk_message: {
pk_reply_to_matrix: {
@@ -4614,6 +4947,69 @@ module.exports = {
flags: 0,
components: []
},
+ klipy_gif: {
+ type: 0,
+ content: "https://klipy.com/gifs/cute-15",
+ mentions: [],
+ mention_roles: [],
+ attachments: [],
+ embeds: [
+ {
+ type: "gifv",
+ url: "https://klipy.com/gifs/cute-15",
+ title: "Cute Corgi Waddle",
+ provider: {
+ name: "Klipy",
+ url: "https://klipy.com"
+ },
+ thumbnail: {
+ url: "https://static.klipy.com/ii/d7aec6f6f171607374b2065c836f92f4/5b/5b/xHVF6sVV.webp",
+ proxy_url: "https://images-ext-1.discordapp.net/external/Z54QmlQflPPb6NoXikflBHGmttgRm3_jhzmcILXHhcA/https/static.klipy.com/ii/d7aec6f6f171607374b2065c836f92f4/5b/5b/xHVF6sVV.webp",
+ width: 277,
+ height: 498,
+ placeholder: "3gcGDAJV+WZYl3RpZ2gGeFBxBw==",
+ placeholder_version: 1,
+ flags: 0
+ },
+ video: {
+ url: "https://static.klipy.com/ii/d7aec6f6f171607374b2065c836f92f4/5b/5b/7ndEhcilPNKJ8O.mp4",
+ proxy_url: "https://images-ext-1.discordapp.net/external/xZspzkQPUKBa74pBhJDpBf3v2d3d0lC943xaB9_JnoM/https/static.klipy.com/ii/d7aec6f6f171607374b2065c836f92f4/5b/5b/7ndEhcilPNKJ8O.mp4",
+ width: 356,
+ height: 640,
+ placeholder: "3gcGDAJV+WZYl3RpZ2gGeFBxBw==",
+ placeholder_version: 1,
+ flags: 0
+ },
+ content_scan_version: 4
+ }
+ ],
+ timestamp: "2026-02-03T11:11:50.070000+00:00",
+ edited_timestamp: null,
+ flags: 0,
+ components: [],
+ id: "1468202316233707613",
+ channel_id: "1370776315266859131",
+ author: {
+ id: "304655299631906816",
+ username: "witterson",
+ avatar: "47ec94a1b2b4cc41ce0329b3575e9b66",
+ discriminator: "0",
+ public_flags: 0,
+ flags: 0,
+ banner: null,
+ accent_color: null,
+ global_name: "wit",
+ avatar_decoration_data: null,
+ collectibles: null,
+ display_name_styles: null,
+ banner_color: null,
+ clan: null,
+ primary_guild: null
+ },
+ pinned: false,
+ mention_everyone: false,
+ tts: false
+ },
tenor_gif: {
type: 0,
content: "<@&1182745800661540927> get real https://tenor.com/view/get-real-gif-26176788",
@@ -4673,6 +5069,183 @@ module.exports = {
tts: false
}
},
+ message_with_components: {
+ pk_question_mark_response: {
+ type: 0,
+ content: '',
+ mentions: [],
+ mention_roles: [],
+ attachments: [],
+ embeds: [],
+ timestamp: '2026-01-30T01:20:07.488000+00:00',
+ edited_timestamp: null,
+ flags: 32768,
+ author: {
+ id: '466378653216014359',
+ username: 'PluralKit',
+ avatar: '466df0c98b1af1e1388f595b4c1ad1b9',
+ discriminator: '0',
+ public_flags: 0,
+ flags: 0,
+ bot: true,
+ banner: null,
+ accent_color: null,
+ global_name: 'PluralKit',
+ avatar_decoration_data: null,
+ collectibles: null,
+ display_name_styles: null,
+ banner_color: null
+ },
+ components: [
+ {
+ type: 17,
+ id: 1,
+ accent_color: 1042150,
+ components: [
+ {
+ type: 9,
+ id: 2,
+ components: [
+ { type: 10, id: 3, content: '### Lillith (INX)' },
+ {
+ type: 10,
+ id: 4,
+ content: '**Display name:** Lillith (she/her)\n' +
+ '**Pronouns:** She/Her\n' +
+ '**Message count:** 3091'
+ }
+ ],
+ accessory: {
+ type: 11,
+ id: 5,
+ media: {
+ id: '1466603856149610687',
+ url: 'https://files.inx.moe/p/cdn/lillith.webp',
+ proxy_url: 'https://images-ext-1.discordapp.net/external/Kn5b32mM4o8AAQbq0k39KOzp9-fy6D1tWKvK_XI27LI/https/files.inx.moe/p/cdn/lillith.webp',
+ width: 256,
+ height: 256,
+ placeholder: 'KVoKJwSnt7lZl5ecj1mal5eGWjAHZXIA',
+ placeholder_version: 1,
+ content_scan_metadata: { version: 4, flags: 0 },
+ content_type: 'image/webp',
+ loading_state: 2,
+ flags: 0
+ },
+ description: null,
+ spoiler: false
+ }
+ },
+ { type: 14, id: 6, spacing: 1, divider: true },
+ {
+ type: 10,
+ id: 7,
+ content: '**Proxy tags:**\n' +
+ '``l;text``\n' +
+ '``l:text``\n' +
+ '``l.text``\n' +
+ '``textl.``\n' +
+ '``textl;``\n' +
+ '``textl:``'
+ }
+ ],
+ spoiler: false
+ },
+ {
+ type: 9,
+ id: 8,
+ components: [
+ {
+ type: 10,
+ id: 9,
+ content: '-# System ID: `xffgnx` ∙ Member ID: `pphhoh`\n' +
+ '-# Created: 2025-12-31 03:16:45 UTC'
+ }
+ ],
+ accessory: {
+ type: 2,
+ id: 10,
+ style: 5,
+ label: 'View on dashboard',
+ url: 'https://dash.pluralkit.me/profile/m/pphhoh'
+ }
+ },
+ { type: 14, id: 11, spacing: 1, divider: true },
+ {
+ type: 17,
+ id: 12,
+ accent_color: null,
+ components: [
+ {
+ type: 9,
+ id: 13,
+ components: [
+ {
+ type: 10,
+ id: 14,
+ content: '**System:** INX (`xffgnx`)\n' +
+ '**Member:** Lillith (`pphhoh`)\n' +
+ '**Sent by:** infinidoge1337 (<@197126718400626689>)\n' +
+ '\n' +
+ '**Account Roles (7)**\n' +
+ '§b, !, ‼, Ears Port Ping, Ears Update Ping, Yttr Ping, unsup Ping'
+ }
+ ],
+ accessory: {
+ type: 11,
+ id: 15,
+ media: {
+ id: '1466603856149610689',
+ url: 'https://files.inx.moe/p/cdn/lillith.webp',
+ proxy_url: 'https://images-ext-1.discordapp.net/external/Kn5b32mM4o8AAQbq0k39KOzp9-fy6D1tWKvK_XI27LI/https/files.inx.moe/p/cdn/lillith.webp',
+ width: 256,
+ height: 256,
+ placeholder: 'KVoKJwSnt7lZl5ecj1mal5eGWjAHZXIA',
+ placeholder_version: 1,
+ content_scan_metadata: { version: 4, flags: 0 },
+ content_type: 'image/webp',
+ loading_state: 2,
+ flags: 0
+ },
+ description: null,
+ spoiler: false
+ }
+ },
+ { type: 14, id: 16, spacing: 2, divider: true },
+ { type: 10, id: 17, content: 'Same hat' },
+ {
+ type: 12,
+ id: 18,
+ items: [
+ {
+ media: {
+ id: '1466603856149610690',
+ url: 'https://cdn.discordapp.com/attachments/934955898965729280/1466556006527012987/image.png?ex=697d2c37&is=697bdab7&hm=09c5028be61ce01ebbdda5c79c42e4dc10d053ce0c4b12c9d84135a0708e9db6&',
+ proxy_url: 'https://media.discordapp.net/attachments/934955898965729280/1466556006527012987/image.png?ex=697d2c37&is=697bdab7&hm=09c5028be61ce01ebbdda5c79c42e4dc10d053ce0c4b12c9d84135a0708e9db6&',
+ width: 285,
+ height: 126,
+ placeholder: '0PcBA4BqSIl9t/dnn9f0rm0=',
+ placeholder_version: 1,
+ content_scan_metadata: { version: 4, flags: 0 },
+ content_type: 'image/png',
+ loading_state: 2,
+ flags: 0
+ },
+ description: null,
+ spoiler: false
+ }
+ ]
+ }
+ ],
+ spoiler: false
+ },
+ {
+ type: 10,
+ id: 19,
+ content: '-# Original Message ID: 1466556003645657118 · '
+ }
+ ]
+ }
+ },
message_update: {
edit_by_webhook: {
application_id: "684280192553844747",
@@ -4932,7 +5505,6 @@ module.exports = {
mention_roles: [],
mentions: [],
pinned: false,
- timestamp: "2023-08-16T22:38:38.641000+00:00",
tts: false,
type: 0
},
@@ -5006,7 +5578,6 @@ module.exports = {
mention_roles: [],
mentions: [],
pinned: false,
- timestamp: "2023-08-16T22:38:38.641000+00:00",
tts: false,
type: 0
},
@@ -5041,7 +5612,6 @@ module.exports = {
pinned: false,
mention_everyone: false,
tts: false,
- timestamp: "2023-05-11T23:44:09.690000+00:00",
edited_timestamp: "2023-05-11T23:44:19.690000+00:00",
flags: 0,
components: [],
@@ -5082,7 +5652,6 @@ module.exports = {
pinned: false,
mention_everyone: false,
tts: false,
- timestamp: "2023-05-11T23:44:09.690000+00:00",
edited_timestamp: "2023-05-11T23:44:19.690000+00:00",
flags: 0,
components: [],
@@ -5123,7 +5692,6 @@ module.exports = {
pinned: false,
mention_everyone: false,
tts: false,
- timestamp: "2023-05-11T23:44:09.690000+00:00",
edited_timestamp: "2023-05-11T23:44:19.690000+00:00",
flags: 0,
components: [],
@@ -5296,6 +5864,36 @@ module.exports = {
guild_id: "112760669178241024",
id: "1210387798297682020"
},
+ embed_generated_social_media_image_for_matrix_user: {
+ channel_id: "112760669178241024",
+ embeds: [
+ {
+ color: 8594767,
+ description: "1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:\n\n * Both players draw eight cards\n * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand\n * Both players present their best five-or-less-card pok...",
+ provider: {
+ name: "hthrflwrs on cohost"
+ },
+ thumbnail: {
+ height: 1587,
+ placeholder: "GpoKP5BJZphshnhwmmmYlmh3l7+m+mwJ",
+ placeholder_version: 1,
+ proxy_url: "https://images-ext-2.discordapp.net/external/9vTXIzlXU4wyUZvWfmlmQkck8nGLUL-A090W4lWsZ48/https/staging.cohostcdn.org/avatar/292-6b64b03c-4ada-42f6-8452-109275bfe68d-profile.png",
+ url: "https://staging.cohostcdn.org/avatar/292-6b64b03c-4ada-42f6-8452-109275bfe68d-profile.png",
+ width: 1644
+ },
+ title: "This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO",
+ type: "link",
+ url: "https://cohost.org/jkap/post/4794219-empty"
+ }
+ ],
+ author: {
+ name: "Matrix Bridge",
+ id: "684280192553844747"
+ },
+ guild_id: "112760669178241024",
+ id: "1128118177155526666",
+ timestamp: "2025-01-01T00:00:00Z"
+ },
embed_generated_on_reply: {
attachments: [],
author: {
@@ -5362,6 +5960,50 @@ module.exports = {
}
},
special_message: {
+ emoji_added: {
+ type: 63,
+ content: '<:cx_marvelous:1437322787994992650>',
+ mentions: [],
+ mention_roles: [],
+ attachments: [],
+ embeds: [],
+ timestamp: '2025-11-10T06:07:36.930000+00:00',
+ edited_timestamp: null,
+ flags: 0,
+ components: [],
+ id: '1437322788439457794',
+ channel_id: '1100319550446252084',
+ author: {
+ id: '772659086046658620',
+ username: 'cadence.worm',
+ avatar: '466df0c98b1af1e1388f595b4c1ad1b9',
+ discriminator: '0',
+ public_flags: 0,
+ flags: 0,
+ banner: null,
+ accent_color: null,
+ global_name: 'cadence',
+ avatar_decoration_data: null,
+ collectibles: null,
+ display_name_styles: null,
+ banner_color: null,
+ clan: {
+ identity_guild_id: '532245108070809601',
+ identity_enabled: true,
+ tag: 'doll',
+ badge: 'dba08126b4e810a0e096cc7cd5bc37f0'
+ },
+ primary_guild: {
+ identity_guild_id: '532245108070809601',
+ identity_enabled: true,
+ tag: 'doll',
+ badge: 'dba08126b4e810a0e096cc7cd5bc37f0'
+ }
+ },
+ pinned: false,
+ mention_everyone: false,
+ tts: false
+ },
thread_name_change: {
id: "1142391602799710298",
type: 4,
diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql
index b31f2c34..1dd9dfed 100644
--- a/test/ooye-test-data.sql
+++ b/test/ooye-test-data.sql
@@ -3,24 +3,30 @@ BEGIN TRANSACTION;
INSERT INTO guild_active (guild_id, autocreate) VALUES
('112760669178241024', 1),
('66192955777486848', 1),
-('665289423482519565', 0);
+('665289423482519565', 0),
+('1345641201902288987', 1);
INSERT INTO guild_space (guild_id, space_id, privacy_level) VALUES
-('112760669178241024', '!jjmvBegULiLucuWEHU:cadence.moe', 0);
+('112760669178241024', '!jjmvBegULiLucuWEHU:cadence.moe', 0),
+('1345641201902288987', '!CvQMeeqXIkgedUpkzv:cadence.moe', 0);
-INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar) VALUES
-('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL, NULL),
-('687028734322147344', '!fGgIymcYWOqjbSRUdV:cadence.moe', 'slow-news-day', NULL, NULL, NULL),
-('497161350934560778', '!CzvdIdUQXgUjDVKxeU:cadence.moe', 'amanda-spam', NULL, NULL, NULL),
-('160197704226439168', '!hYnGGlPHlbujVVfktC:cadence.moe', 'the-stanley-parable-channel', 'bots', NULL, NULL),
-('1100319550446252084', '!BnKuBPCvyfOkhcUjEu:cadence.moe', 'worm-farm', NULL, NULL, NULL),
-('1162005314908999790', '!FuDZhlOAtqswlyxzeR:cadence.moe', 'Hey.', NULL, '1100319550446252084', NULL),
-('297272183716052993', '!rEOspnYqdOalaIFniV:cadence.moe', 'general', NULL, NULL, NULL),
-('122155380120748034', '!cqeGDbPiMFAhLsqqqq:cadence.moe', 'cadences-mind', 'coding', NULL, NULL),
-('176333891320283136', '!qzDBLKlildpzrrOnFZ:cadence.moe', '🌈丨davids-horse_she-took-the-kids', 'wonderland', NULL, 'mxc://cadence.moe/EVvrSkKIRONHjtRJsMLmHWLS'),
-('489237891895768942', '!tnedrGVYKFNUdnegvf:tchncs.de', 'ex-room-doesnt-exist-any-more', NULL, NULL, NULL),
-('1160894080998461480', '!TqlyQmifxGUggEmdBN:cadence.moe', 'ooyexperiment', NULL, NULL, NULL),
-('1161864271370666075', '!mHmhQQPwXNananMUqq:cadence.moe', 'updates', NULL, NULL, NULL);
+INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, guild_id) VALUES
+('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL, NULL, '112760669178241024'),
+('687028734322147344', '!fGgIymcYWOqjbSRUdV:cadence.moe', 'slow-news-day', NULL, NULL, NULL, '112760669178241024'),
+('497161350934560778', '!CzvdIdUQXgUjDVKxeU:cadence.moe', 'amanda-spam', NULL, NULL, NULL, '66192955777486848'),
+('160197704226439168', '!hYnGGlPHlbujVVfktC:cadence.moe', 'the-stanley-parable-channel', 'bots', NULL, NULL, '112760669178241024'),
+('1100319550446252084', '!BnKuBPCvyfOkhcUjEu:cadence.moe', 'worm-farm', NULL, NULL, NULL, '66192955777486848'),
+('1162005314908999790', '!FuDZhlOAtqswlyxzeR:cadence.moe', 'Hey.', NULL, '1100319550446252084', NULL, '112760669178241024'),
+('297272183716052993', '!rEOspnYqdOalaIFniV:cadence.moe', 'general', NULL, NULL, NULL, '66192955777486848'),
+('122155380120748034', '!cqeGDbPiMFAhLsqqqq:cadence.moe', 'cadences-mind', 'coding', NULL, NULL, '112760669178241024'),
+('176333891320283136', '!qzDBLKlildpzrrOnFZ:cadence.moe', '🌈丨davids-horse_she-took-the-kids', 'wonderland', NULL, 'mxc://cadence.moe/EVvrSkKIRONHjtRJsMLmHWLS', '112760669178241024'),
+('489237891895768942', '!tnedrGVYKFNUdnegvf:tchncs.de', 'ex-room-doesnt-exist-any-more', NULL, NULL, NULL, '66192955777486848'),
+('1160894080998461480', '!TqlyQmifxGUggEmdBN:cadence.moe', 'ooyexperiment', NULL, NULL, NULL, '66192955777486848'),
+('1161864271370666075', '!mHmhQQPwXNananMUqq:cadence.moe', 'updates', NULL, NULL, NULL, '112760669178241024'),
+('1438284564815548418', '!MHxNpwtgVqWOrmyoTn:cadence.moe', 'sin-cave', NULL, NULL, NULL, '665289423482519565'),
+('598707048112193536', '!JBxeGYnzQwLnaooOLD:cadence.moe', 'winners', NULL, NULL, NULL, '1345641201902288987');
+
+INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) SELECT channel_id, room_id, 0 FROM channel_room;
INSERT INTO sim (user_id, username, sim_name, mxid) VALUES
('0', 'Matrix Bridge', 'bot', '@_ooye_bot:cadence.moe'),
@@ -41,7 +47,8 @@ INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES
INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES
('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺');
-INSERT INTO message_channel (message_id, channel_id) VALUES
+INSERT INTO message_room (message_id, historical_room_index)
+WITH a (message_id, channel_id) AS (VALUES
('1106366167788044450', '122155380120748034'),
('1106366167788044451', '122155380120748034'),
('1106366167788044452', '122155380120748034'),
@@ -73,7 +80,10 @@ INSERT INTO message_channel (message_id, channel_id) VALUES
('1144874214311067708', '687028734322147344'),
('1339000288144658482', '176333891320283136'),
('1381212840957972480', '112760669178241024'),
-('1401760355339862066', '112760669178241024');
+('1401760355339862066', '112760669178241024'),
+('1439351590262800565', '1438284564815548418'),
+('1404133238414376971', '112760669178241024'))
+SELECT message_id, max(historical_room_index) as historical_room_index FROM a INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = a.channel_id GROUP BY message_id;
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1),
@@ -88,8 +98,8 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part
('$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q', 'm.room.message', 'm.text', '1128084748338741392', 0, 0, 1),
('$FchUVylsOfmmbj-VwEs5Z9kY49_dt2zd0vWfylzy5Yo', 'm.room.message', 'm.text', '1143121514925928541', 0, 0, 1),
('$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4', 'm.room.message', 'm.text', '1106366167788044450', 0, 1, 1),
-('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZIgEU', 'm.room.message', 'm.image', '1106366167788044450', 1, 1, 0),
-('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd8f8', 'm.sticker', NULL, '1106366167788044450', 1, 0, 0),
+('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZIgEU', 'm.room.message', 'm.image', '1106366167788044450', 1, 1, 1),
+('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd8f8', 'm.sticker', NULL, '1106366167788044450', 1, 0, 1),
('$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999', 'm.room.message', 'm.text', '1106366167788044451', 0, 0, 1),
('$Ijf1MFCD39ktrNHxrA-i2aKoRWNYdAV2ZXYQeiZI999', 'm.room.message', 'm.image', '1106366167788044451', 0, 0, 1),
('$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd999', 'm.sticker', NULL, '1106366167788044451', 0, 0, 1),
@@ -117,7 +127,10 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part
('$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk', 'm.room.message', 'm.text', '1339000288144658482', 0, 0, 0),
('$AfrB8hzXkDMvuoWjSZkDdFYomjInWH7jMBPkwQMN8AI', 'm.room.message', 'm.text', '1381212840957972480', 0, 1, 1),
('$43baKEhJfD-RlsFQi0LB16Zxd8yMqp0HSVL00TDQOqM', 'm.room.message', 'm.image', '1381212840957972480', 1, 0, 1),
-('$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk', 'm.room.message', 'm.text', '1401760355339862066', 0, 0, 0);
+('$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk', 'm.room.message', 'm.text', '1401760355339862066', 0, 0, 0),
+('$ielAnR6geu0P1Tl5UXfrbxlIf-SV9jrNprxrGXP3v7M', 'm.room.message', 'm.image', '1439351590262800565', 0, 0, 0),
+('$uUKLcTQvik5tgtTGDKuzn0Ci4zcCvSoUcYn2X7mXm9I', 'm.room.message', 'm.text', '1404133238414376971', 0, 1, 1),
+('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1);
INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),
@@ -132,13 +145,15 @@ INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/emojis/230201364309868544.png', 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),
('https://cdn.discordapp.com/emojis/393635038903926784.gif', 'mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc'),
('https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg', 'mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR'),
-('https://cdn.discordapp.com/emojis/1125827250609201255.png', 'mxc://cadence.moe/pgdGTxAyEltccRgZKxdqzHHP'),
+('https://cdn.discordapp.com/emojis/1125827250609201255.webp', 'mxc://cadence.moe/pgdGTxAyEltccRgZKxdqzHHP'),
('https://cdn.discordapp.com/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024', 'mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX'),
('https://cdn.discordapp.com/emojis/288858540888686602.png', 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO'),
('https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4', 'mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU'),
('https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg', 'mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB'),
('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP'),
-('https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif', 'mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh');
+('https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif', 'mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh'),
+('https://cdn.discordapp.com/attachments/123/456/my_enemies.txt', 'mxc://cadence.moe/y89EOTRp2lbeOkgdsEleGOge'),
+('https://cdn.discordapp.com/emojis/1254940125948022915.webp', 'mxc://cadence.moe/bvVJFgOIyNcAknKCbmaHDktG');
INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES
('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),
@@ -148,7 +163,8 @@ INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES
('551636841284108289', 'ae_botrac4r', 0, 'mxc://cadence.moe/skqfuItqxNmBYekzmVKyoLzs'),
('975572106295259148', 'brillillillilliant_move', 0, 'mxc://cadence.moe/scfRIDOGKWFDEBjVXocWYQHik'),
('606664341298872324', 'online', 0, 'mxc://cadence.moe/LCEqjStXCxvRQccEkuslXEyZ'),
-('288858540888686602', 'upstinky', 0, 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO');
+('288858540888686602', 'upstinky', 0, 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO'),
+('1437322787994992650', 'cx_marvelous', 0, 'mxc://cadence.moe/TPZdosVUjTIopsLijkygIbti');
INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES
('!jjmvBegULiLucuWEHU:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 50),
@@ -163,13 +179,14 @@ INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) V
('!TqlyQmifxGUggEmdBN:cadence.moe', '@ampflower:matrix.org', 'Ampflower 🌺', 'mxc://cadence.moe/PRfhXYBTOalvgQYtmCLeUXko', 0),
('!TqlyQmifxGUggEmdBN:cadence.moe', '@aflower:syndicated.gay', 'Rose', 'mxc://syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP', 0),
('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0),
-('!iSyXgNxQcEuXoXpsSn:pussthecat.org', '@austin:tchncs.de', 'Austin Huang', 'mxc://tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e', 0);
+('!iSyXgNxQcEuXoXpsSn:pussthecat.org', '@austin:tchncs.de', 'Austin Huang', 'mxc://tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e', 0),
+('!zq94fae5bVKUubZLp7:agiadn.org', '@underscore_x:agiadn.org', 'underscore_x', NULL, 100);
INSERT INTO reaction (hashed_event_id, message_id, encoded_emoji) VALUES
(5162930312280790092, '1141501302736695317', '%F0%9F%90%88');
INSERT INTO member_power (mxid, room_id, power_level) VALUES
-('@test_auto_invite:example.org', '*', 100);
+('@test_auto_invite:example.org', '*', 150);
INSERT INTO lottie (sticker_id, mxc_url) VALUES
('860171525772279849', 'mxc://cadence.moe/ZtvvVbwMIdUZeovWVyGVFCeR');
@@ -192,4 +209,10 @@ INSERT INTO direct (mxid, room_id) VALUES
('@user1:example.org', '!existing:cadence.moe'),
('@user2:example.org', '!existing:cadence.moe');
+-- for cross-room reply test, in 'updates' room
+UPDATE historical_channel_room SET room_id = '!mHmhQQPwXNananaOLD:cadence.moe' WHERE room_id = '!mHmhQQPwXNananMUqq:cadence.moe';
+INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) VALUES ('1161864271370666075', '!mHmhQQPwXNananMUqq:cadence.moe', 1767922455991);
+INSERT INTO message_room (message_id, historical_room_index) SELECT '1458091145136443547', historical_room_index FROM historical_channel_room WHERE room_id = '!mHmhQQPwXNananaOLD:cadence.moe';
+INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES ('$pgzCQjq_y5sy8RvWOUuoF3obNHjs8iNvt9c-odrOCPY', 'm.room.message', 'm.image', '1458091145136443547', 0, 0, 0);
+
COMMIT;
diff --git a/test/test.js b/test/test.js
index b01f0ce2..e05b687d 100644
--- a/test/test.js
+++ b/test/test.js
@@ -28,6 +28,9 @@ reg.namespaces = {
}
reg.ooye.bridge_origin = "https://bridge.example.org"
reg.ooye.time_zone = "Pacific/Auckland"
+reg.ooye.max_file_size = 5000000
+reg.ooye.web_password = "password123"
+reg.ooye.include_user_id_in_mxid = false
const sync = new HeatSync({watchFS: false})
@@ -72,47 +75,45 @@ const file = sync.require("../src/matrix/file")
file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not allowed to upload files during testing.\nURL: ${url}`) }
;(async () => {
- /* c8 ignore start - maybe download some more test files in slow mode */
- if (process.argv.includes("--slow")) {
- test("test files: download", async t => {
- /** @param {{url: string, to: string}[]} files */
- async function allReporter(files) {
- return new Promise(resolve => {
- let resolved = 0
- const report = files.map(file => file.to.split("/").slice(-1)[0][0])
- files.map(download).forEach((p, i) => {
- p.then(() => {
- report[i] = green(".")
- process.stderr.write("\r" + report.join(""))
- if (++resolved === files.length) resolve(null)
- })
+ /* c8 ignore start - download some more test files in slow mode */
+ test("test files: download", async t => {
+ /** @param {{url: string, to: string}[]} files */
+ async function allReporter(files) {
+ return new Promise(resolve => {
+ let resolved = 0
+ const report = files.map(file => file.to.split("/").slice(-1)[0][0])
+ files.map(download).forEach((p, i) => {
+ p.then(() => {
+ report[i] = green(".")
+ process.stderr.write("\r" + report.join(""))
+ if (++resolved === files.length) resolve(null)
})
})
- }
- async function download({url, to}) {
- if (await fs.existsSync(to)) return
- const res = await fetch(url)
- // @ts-ignore
- await res.body.pipeTo(Writable.toWeb(fs.createWriteStream(to, {encoding: "binary"})))
- }
- await allReporter([
- {url: "https://cadence.moe/friends/ooye_test/RLMgJGfgTPjIQtvvWZsYjhjy.png", to: "test/res/RLMgJGfgTPjIQtvvWZsYjhjy.png"},
- {url: "https://cadence.moe/friends/ooye_test/bZFuuUSEebJYXUMSxuuSuLTa.png", to: "test/res/bZFuuUSEebJYXUMSxuuSuLTa.png"},
- {url: "https://cadence.moe/friends/ooye_test/qWmbXeRspZRLPcjseyLmeyXC.png", to: "test/res/qWmbXeRspZRLPcjseyLmeyXC.png"},
- {url: "https://cadence.moe/friends/ooye_test/wcouHVjbKJJYajkhJLsyeJAA.png", to: "test/res/wcouHVjbKJJYajkhJLsyeJAA.png"},
- {url: "https://cadence.moe/friends/ooye_test/WbYqNlACRuicynBfdnPYtmvc.gif", to: "test/res/WbYqNlACRuicynBfdnPYtmvc.gif"},
- {url: "https://cadence.moe/friends/ooye_test/HYcztccFIPgevDvoaWNsEtGJ.png", to: "test/res/HYcztccFIPgevDvoaWNsEtGJ.png"},
- {url: "https://cadence.moe/friends/ooye_test/lHfmJpzgoNyNtYHdAmBHxXix.png", to: "test/res/lHfmJpzgoNyNtYHdAmBHxXix.png"},
- {url: "https://cadence.moe/friends/ooye_test/MtRdXixoKjKKOyHJGWLsWLNU.png", to: "test/res/MtRdXixoKjKKOyHJGWLsWLNU.png"},
- {url: "https://cadence.moe/friends/ooye_test/HXfFuougamkURPPMflTJRxGc.png", to: "test/res/HXfFuougamkURPPMflTJRxGc.png"},
- {url: "https://cadence.moe/friends/ooye_test/ikYKbkhGhMERAuPPbsnQzZiX.png", to: "test/res/ikYKbkhGhMERAuPPbsnQzZiX.png"},
- {url: "https://cadence.moe/friends/ooye_test/AYPpqXzVJvZdzMQJGjioIQBZ.png", to: "test/res/AYPpqXzVJvZdzMQJGjioIQBZ.png"},
- {url: "https://cadence.moe/friends/ooye_test/UVuzvpVUhqjiueMxYXJiFEAj.png", to: "test/res/UVuzvpVUhqjiueMxYXJiFEAj.png"},
- {url: "https://ezgif.com/images/format-demo/butterfly.gif", to: "test/res/butterfly.gif"},
- {url: "https://ezgif.com/images/format-demo/butterfly.png", to: "test/res/butterfly.png"},
- ])
- }, {timeout: 60000})
- }
+ })
+ }
+ async function download({url, to}) {
+ if (await fs.existsSync(to)) return
+ const res = await fetch(url)
+ // @ts-ignore
+ await res.body.pipeTo(Writable.toWeb(fs.createWriteStream(to, {encoding: "binary"})))
+ }
+ await allReporter([
+ {url: "https://cadence.moe/friends/ooye_test/RLMgJGfgTPjIQtvvWZsYjhjy.png", to: "test/res/RLMgJGfgTPjIQtvvWZsYjhjy.png"},
+ {url: "https://cadence.moe/friends/ooye_test/bZFuuUSEebJYXUMSxuuSuLTa.png", to: "test/res/bZFuuUSEebJYXUMSxuuSuLTa.png"},
+ {url: "https://cadence.moe/friends/ooye_test/qWmbXeRspZRLPcjseyLmeyXC.png", to: "test/res/qWmbXeRspZRLPcjseyLmeyXC.png"},
+ {url: "https://cadence.moe/friends/ooye_test/wcouHVjbKJJYajkhJLsyeJAA.png", to: "test/res/wcouHVjbKJJYajkhJLsyeJAA.png"},
+ {url: "https://cadence.moe/friends/ooye_test/WbYqNlACRuicynBfdnPYtmvc.gif", to: "test/res/WbYqNlACRuicynBfdnPYtmvc.gif"},
+ {url: "https://cadence.moe/friends/ooye_test/HYcztccFIPgevDvoaWNsEtGJ.png", to: "test/res/HYcztccFIPgevDvoaWNsEtGJ.png"},
+ {url: "https://cadence.moe/friends/ooye_test/lHfmJpzgoNyNtYHdAmBHxXix.png", to: "test/res/lHfmJpzgoNyNtYHdAmBHxXix.png"},
+ {url: "https://cadence.moe/friends/ooye_test/MtRdXixoKjKKOyHJGWLsWLNU.png", to: "test/res/MtRdXixoKjKKOyHJGWLsWLNU.png"},
+ {url: "https://cadence.moe/friends/ooye_test/HXfFuougamkURPPMflTJRxGc.png", to: "test/res/HXfFuougamkURPPMflTJRxGc.png"},
+ {url: "https://cadence.moe/friends/ooye_test/ikYKbkhGhMERAuPPbsnQzZiX.png", to: "test/res/ikYKbkhGhMERAuPPbsnQzZiX.png"},
+ {url: "https://cadence.moe/friends/ooye_test/AYPpqXzVJvZdzMQJGjioIQBZ.png", to: "test/res/AYPpqXzVJvZdzMQJGjioIQBZ.png"},
+ {url: "https://cadence.moe/friends/ooye_test/UVuzvpVUhqjiueMxYXJiFEAj.png", to: "test/res/UVuzvpVUhqjiueMxYXJiFEAj.png"},
+ {url: "https://ezgif.com/images/format-demo/butterfly.gif", to: "test/res/butterfly.gif"},
+ {url: "https://ezgif.com/images/format-demo/butterfly.png", to: "test/res/butterfly.png"},
+ ])
+ }, {timeout: 60000})
/* c8 ignore stop */
const p = migrate.migrate(db)
@@ -132,29 +133,26 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
require("./addbot.test")
require("../src/db/orm.test")
require("../src/web/server.test")
- require("../src/web/routes/download-discord.test")
- require("../src/web/routes/download-matrix.test")
- require("../src/web/routes/guild.test")
- require("../src/web/routes/guild-settings.test")
- require("../src/web/routes/info.test")
- require("../src/web/routes/link.test")
- require("../src/web/routes/log-in-with-matrix.test")
require("../src/discord/utils.test")
require("../src/matrix/kstate.test")
require("../src/matrix/api.test")
require("../src/matrix/file.test")
require("../src/matrix/mreq.test")
require("../src/matrix/read-registration.test")
+ require("../src/matrix/room-upgrade.test")
require("../src/matrix/txnid.test")
+ require("../src/matrix/utils.test")
require("../src/d2m/actions/create-room.test")
require("../src/d2m/actions/create-space.test")
require("../src/d2m/actions/register-user.test")
require("../src/d2m/converters/edit-to-changes.test")
require("../src/d2m/converters/emoji-to-key.test")
+ require("../src/d2m/converters/find-mentions.test")
require("../src/d2m/converters/lottie.test")
require("../src/d2m/converters/message-to-event.test")
- require("../src/d2m/converters/message-to-event.embeds.test")
- require("../src/d2m/converters/message-to-event.pk.test")
+ require("../src/d2m/converters/message-to-event.test.components")
+ require("../src/d2m/converters/message-to-event.test.embeds")
+ require("../src/d2m/converters/message-to-event.test.pk")
require("../src/d2m/converters/pins-to-list.test")
require("../src/d2m/converters/remove-reaction.test")
require("../src/d2m/converters/thread-to-announcement.test")
@@ -163,11 +161,19 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
require("../src/m2d/converters/diff-pins.test")
require("../src/m2d/converters/event-to-message.test")
require("../src/m2d/converters/emoji.test")
- require("../src/m2d/converters/utils.test")
require("../src/m2d/converters/emoji-sheet.test")
require("../src/discord/interactions/invite.test")
require("../src/discord/interactions/matrix-info.test")
require("../src/discord/interactions/permissions.test")
require("../src/discord/interactions/privacy.test")
require("../src/discord/interactions/reactions.test")
+ require("../src/web/routes/download-discord.test")
+ require("../src/web/routes/download-matrix.test")
+ require("../src/web/routes/guild.test")
+ require("../src/web/routes/guild-settings.test")
+ require("../src/web/routes/info.test")
+ require("../src/web/routes/link.test")
+ require("../src/web/routes/log-in-with-matrix.test")
+ require("../src/web/routes/oauth.test")
+ require("../src/web/routes/password.test")
})()
diff --git a/test/web.js b/test/web.js
index 09af95bb..250694aa 100644
--- a/test/web.js
+++ b/test/web.js
@@ -51,7 +51,7 @@ class Router {
/**
* @param {string} method
* @param {string} inputUrl
- * @param {{event?: any, params?: any, body?: any, sessionData?: any, api?: Partial, snow?: {[k in keyof SnowTransfer]?: Partial}, createRoom?: Partial, createSpace?: Partial, headers?: any}} [options]
+ * @param {{event?: any, params?: any, body?: any, sessionData?: any, getOauth2Token?: any, getClient?: (string) => {user: {getGuilds: () => Promise}}, api?: Partial, snow?: {[k in keyof SnowTransfer]?: Partial}, createRoom?: Partial, createSpace?: Partial, mxcDownloader?: import("../src/m2d/actions/emoji-sheet")["getAndConvertEmoji"], headers?: any}} [options]
*/
async test(method, inputUrl, options = {}) {
const url = new URL(inputUrl, "http://a")
@@ -83,10 +83,13 @@ class Router {
},
context: {
api: options.api,
+ mxcDownloader: options.mxcDownloader,
params: options.params,
snow: options.snow,
createRoom: options.createRoom,
createSpace: options.createSpace,
+ getOauth2Token: options.getOauth2Token,
+ getClient: options.getClient,
sessions: {
h3: {
id: "h3",