, 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
In reply to a Discord user
[Replied-to message content wasn't provided by Discord]
In reply to a Discord user
[Replied-to message content wasn't provided by Discord]
${emoji} Uploaded SPOILER file: ${publicURL} (${pb(attachment.size)})` + formatted_body: `
${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})` } } // for large files, always link them instead of uploading so I don't use up all the space in the content repo - else if (attachment.size > reg.ooye.max_file_size) { + else if (alwaysLink || attachment.size > reg.ooye.max_file_size) { return { $type: "m.room.message", "m.mentions": mentions, msgtype: "m.text", - body: `${emoji} Uploaded file: ${publicURL} (${pb(attachment.size)})`, + body: `${emoji} Uploaded file: ${external_url} (${pb(attachment.size)})`, format: "org.matrix.custom.html", - formatted_body: `${emoji} Uploaded file: ${attachment.filename} (${pb(attachment.size)})` + formatted_body: `${emoji} Uploaded file: ${attachment.filename} (${pb(attachment.size)})` } } else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) { return { @@ -138,7 +148,7 @@ async function attachmentToEvent(mentions, attachment) { "m.mentions": mentions, msgtype: "m.image", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url: attachment.url, + external_url, body: attachment.description || attachment.filename, filename: attachment.filename, info: { @@ -154,7 +164,7 @@ async function attachmentToEvent(mentions, attachment) { "m.mentions": mentions, msgtype: "m.video", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url: attachment.url, + external_url, body: attachment.description || attachment.filename, filename: attachment.filename, info: { @@ -170,13 +180,13 @@ async function attachmentToEvent(mentions, attachment) { "m.mentions": mentions, msgtype: "m.audio", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url: attachment.url, + external_url, body: attachment.description || attachment.filename, filename: attachment.filename, info: { mimetype: attachment.content_type, size: attachment.size, - duration: attachment.duration_secs ? attachment.duration_secs * 1000 : undefined + duration: attachment.duration_secs && Math.round(attachment.duration_secs * 1000) } } } else { @@ -185,7 +195,7 @@ async function attachmentToEvent(mentions, attachment) { "m.mentions": mentions, msgtype: "m.file", url: await file.uploadDiscordFileToMxc(attachment.url), - external_url: attachment.url, + external_url, body: attachment.description || attachment.filename, filename: attachment.filename, info: { @@ -196,15 +206,74 @@ async function attachmentToEvent(mentions, attachment) { } } +/** @param {DiscordTypes.APIPoll} poll */ +async function pollToEvent(poll) { + let fallbackText = poll.question.text + if (poll.allow_multiselect) { + var maxSelections = poll.answers.length; + } else { + var maxSelections = 1; + } + let answers = poll.answers.map(answer=>{ + let matrixText = answer.poll_media.text + if (answer.poll_media.emoji) { + if (answer.poll_media.emoji.id) { + // Custom emoji. It seems like no Matrix client allows custom emoji in poll answers, so leaving this unimplemented. + } else { + matrixText = "[" + answer.poll_media.emoji.name + "] " + matrixText + } + } + let matrixAnswer = { + id: answer.answer_id.toString(), + "org.matrix.msc1767.text": matrixText + } + fallbackText = fallbackText + "\n" + answer.answer_id.toString() + ". " + matrixText + return matrixAnswer; + }) + return { + /** @type {"org.matrix.msc3381.poll.start"} */ + $type: "org.matrix.msc3381.poll.start", + "org.matrix.msc3381.poll.start": { + question: { + "org.matrix.msc1767.text": poll.question.text, + body: poll.question.text, + msgtype: "m.text" + }, + kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. + max_selections: maxSelections, + answers: answers + }, + "org.matrix.msc1767.text": fallbackText + } +} + /** - * @param {import("discord-api-types/v10").APIMessage} message - * @param {import("discord-api-types/v10").APIGuild} guild - * @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean}} options default values: + * @param {DiscordTypes.APIMessageInteraction} interaction + * @param {boolean} isThinkingInteraction + */ +function getFormattedInteraction(interaction, isThinkingInteraction) { + const mxid = select("sim", "mxid", {user_id: interaction.user.id}).pluck().get() + const username = interaction.member?.nick || interaction.user.global_name || interaction.user.username + const thinkingText = isThinkingInteraction ? " — interaction loading..." : "" + return { + body: `↪️ ${username} used \`/${interaction.name}\`${thinkingText}`, + html: `
↪️ ${mxid ? tag`${username}` : username} used /${interaction.name}${thinkingText}`
+ }
+}
+
+/**
+ * @param {DiscordTypes.APIMessage} message
+ * @param {DiscordTypes.APIGuild} guild
+ * @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean, alwaysReturnFormattedBody?: boolean, scanTextForMentions?: boolean}} options default values:
* - includeReplyFallback: true
* - includeEditFallbackStar: false
- * @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
+ * - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true.
+ * - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned.
+ * @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer}} di simple-as-nails dependency injection for the matrix API
+ * @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 */
@@ -216,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.
@@ -234,11 +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.
- if (message.content) message.content = `\n${message.content}`
- message.content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${message.content}`
- }
+ 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[]}}
@@ -257,7 +355,10 @@ 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, channel_id: string}?} */
let repliedToEventRow = null
+ let repliedToEventInDifferentRoom = false
+ let repliedToUnknownEvent = false
let repliedToEventSenderMxid = null
if (message.mention_everyone) mentions.room = true
@@ -270,9 +371,11 @@ 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
}
} else if (dUtils.isWebhookMessage(message) && message.embeds[0]?.author?.name?.endsWith("↩️")) {
// It could be a PluralKit emulated reply, let's see if it has a message link
@@ -282,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:
@@ -301,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})
}
}
}
@@ -328,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.
@@ -339,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}` + + `` + + 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 + } } - let repliedToContent = message.referenced_message?.content - if (repliedToContent?.match(/^(-# )?> (-# )?<:L1:/)) { - // If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote. - // Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message. - // ┌──────A─────┐ A reply rep starting with >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]" - else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]" - const repliedToHtml = markdown.toHTML(repliedToContent, { - discordCallback: getDiscordParseCallbacks(message, guild, true) - }) - const repliedToBody = markdown.toHTML(repliedToContent, { - discordCallback: getDiscordParseCallbacks(message, guild, false), - discordOnly: true, - escapeHTML: false, - }) - html = `
${repliedToHtml}
In reply to ${repliedToUserHtml}` - + `
${repliedToHtml}
${event.formatted_body}` + } + } + + // Try to merge the forwarded content with the forwarded notice + let {body, formatted_body} = forwardedNotice.get() + if (forwardedEvents.length >= 1 && ["m.text", "m.notice"].includes(forwardedEvents[0].msgtype)) { // Try to merge the forwarded content and the forwarded notice + forwardedEvents[0].body = body + "\n" + forwardedEvents[0].body + forwardedEvents[0].formatted_body = formatted_body + "
${html}` + await addTextEvent(body, html, "m.notice") + } } // 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 + const urlPreviewEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1 for (const embed of message.embeds || []) { + if (!urlPreviewEnabled && !message.author?.bot) { + continue // show embeds for everyone if enabled, or bot users only if disabled (bots often send content in embeds) + } + if (embed.type === "image") { 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) { + if (embed.provider?.name && embed.provider.name !== "Tenor") { if (embed.provider.url) { rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`${embed.provider.name}`) } else { @@ -599,9 +1030,9 @@ async function messageToEvent(message, guild, options = {}, di) { let chosenImage = embed.image?.url // the thumbnail seems to be used for "article" type but displayed big at the bottom by discord if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url - if (chosenImage) rep.addParagraph(`📸 ${chosenImage}`) + if (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`) - if (embed.video?.url) rep.addParagraph(`🎞️ ${embed.video.url}`) + if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`) if (embed.footer?.text) rep.addLine(`— ${embed.footer.text}`, tag`— ${embed.footer.text}`) let {body, formatted_body: html} = rep.get() @@ -609,7 +1040,7 @@ async function messageToEvent(message, guild, options = {}, di) { html = `
${html}` // Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person - await addTextEvent(body, html, "m.notice", {scanMentions: false}) + await addTextEvent(body, html, "m.notice") } // Then stickers @@ -645,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": { @@ -655,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 73% rename from src/d2m/converters/message-to-event.embeds.test.js rename to src/d2m/converters/message-to-event.test.embeds.js index ef7e9b8d..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 Ty = require("../../types") +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 => { @@ -150,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' @@ -33,7 +41,9 @@ test("message2event embeds: reply with just an embed", async t => { $type: "m.room.message", msgtype: "m.notice", "m.mentions": {}, - body: "| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145" + body: "> In reply to an unbridged message:" + + "\n> PokemonGod: https://twitter.com/dynastic/status/1707484191963648161" + + "\n\n| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145" + "\n| \n| does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?" + "\n| \n| ### Retweets" + "\n| 119" @@ -41,7 +51,8 @@ test("message2event embeds: reply with just an embed", async t => { + "\n| 5581" + "\n| — Twitter", format: "org.matrix.custom.html", - formatted_body: 'Amanda 🎵#2192
' + '
willow tree, branch 0' + '
❯ Uptime:
3m 55s' + '
❯ Memory:
64.45MB⏺️ dynastic (@dynastic)' + formatted_body: '
In reply to an unbridged message from PokemonGod:' + + '
https://twitter.com/dynastic/status/1707484191963648161' }]) @@ -67,7 +78,7 @@ test("message2event embeds: image embed and attachment", async t => { msgtype: "m.image", url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR", body: "Screenshot_20231001_034036.jpg", - external_url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&", + external_url: "https://bridge.example.org/download/discordcdn/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg", filename: "Screenshot_20231001_034036.jpg", info: { h: 1170, @@ -83,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") @@ -121,7 +122,7 @@ test("message2event embeds: blockquote in embed", async t => { formatted_body: "does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?' + '
Retweets
119Likes
— Twitter
5581reply 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": {} }]) }) @@ -169,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": {} }]) }) @@ -188,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": {} }]) }) @@ -207,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": {} }]) }) @@ -318,19 +299,56 @@ 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!
", + "m.mentions": {} + }]) +}) + +test("message2event embeds: klipy gif should send in customised format", async t => { + const events = await messageToEvent(data.message_with_embeds.klipy_gif, data.guild.general, {}, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "[GIF] Cute Corgi Waddle https://static.klipy.com/ii/d7aec6f6f171607374b2065c836f92f4/5b/5b/7ndEhcilPNKJ8O.mp4", + format: "org.matrix.custom.html", + formatted_body: "🎞️ https://media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4
➿ 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 { @@ -351,3 +369,16 @@ test("message2event embeds: if discord creates an embed preview for a discord ch "m.mentions": {} }]) }) + +test("message2event embeds: nothing generated if embeds are disabled in settings", async t => { + db.prepare("UPDATE guild_space SET url_preview = 0 WHERE guild_id = ?").run(data.guild.general.id) + const events = await messageToEvent(data.message_with_embeds.youtube_video, data.guild.general) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E\n\n\nJutomi I'm gonna make these sounds in your walls tonight", + format: "org.matrix.custom.html", + formatted_body: `https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E
In reply to Extremity' - + '
Image
In reply to cadence' - + '
so can you reply to my webhook uwu
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?"
In reply to a 1-day-old unbridged message from Occimyy:enigmatic`, + "m.mentions": {} }]) }) @@ -780,11 +789,13 @@ test("message2event: simple written @mention for matrix user", async t => { ] }, msgtype: "m.text", - body: "@ash do you need anything from the store btw as I'm heading there after gym" + body: "[@ash](https://matrix.to/#/@she_who_brings_destruction:cadence.moe) do you need anything from the store btw as I'm heading there after gym", + format: "org.matrix.custom.html", + formatted_body: `@ash do you need anything from the store btw as I'm heading there after gym` }]) }) -test("message2event: advanced written @mentions for matrix users", async t => { +test("message2event: many written @mentions for matrix users", async t => { let called = 0 const events = await messageToEvent(data.message.advanced_written_at_mention_for_matrix, data.guild.general, {}, { api: { @@ -822,16 +833,200 @@ test("message2event: advanced written @mentions for matrix users", async t => { $type: "m.room.message", "m.mentions": { user_ids: [ - "@cadence:cadence.moe", - "@huckleton:cadence.moe" + "@huckleton:cadence.moe", + "@cadence:cadence.moe" ] }, msgtype: "m.text", - body: "@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck" + body: "[@Cadence](https://matrix.to/#/@cadence:cadence.moe), tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and [@huck](https://matrix.to/#/@huckleton:cadence.moe)", + format: "org.matrix.custom.html", + formatted_body: `@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck` }]) t.equal(called, 1, "should only look up the member list once") }) +test("message2event: written @mentions may match part of the name", async t => { + let called = 0 + const events = await messageToEvent({ + ...data.message.advanced_written_at_mention_for_matrix, + content: "I wonder if @cadence saw this?" + }, data.guild.general, {}, { + api: { + async getJoinedMembers(roomID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + return new Promise(resolve => { + setTimeout(() => { + resolve({ + joined: { + "@secret:cadence.moe": { + display_name: "cadence [they]", + avatar_url: "whatever" + }, + "@huckleton:cadence.moe": { + display_name: "huck", + avatar_url: "whatever" + }, + "@_ooye_botrac4r:cadence.moe": { + display_name: "botrac4r", + avatar_url: "whatever" + }, + "@_ooye_bot:cadence.moe": { + display_name: "Out Of Your Element", + avatar_url: "whatever" + } + } + }) + }) + }) + } + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.mentions": { + user_ids: [ + "@secret:cadence.moe", + ] + }, + msgtype: "m.text", + body: "I wonder if [@cadence](https://matrix.to/#/@secret:cadence.moe) saw this?", + format: "org.matrix.custom.html", + formatted_body: `I wonder if @cadence saw this?` + }]) +}) + +test("message2event: written @mentions may match part of the mxid", async t => { + let called = 0 + const events = await messageToEvent({ + ...data.message.advanced_written_at_mention_for_matrix, + content: "I wonder if @huck saw this?" + }, data.guild.general, {}, { + api: { + async getJoinedMembers(roomID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + return new Promise(resolve => { + setTimeout(() => { + resolve({ + joined: { + "@cadence:cadence.moe": { + display_name: "cadence [they]", + avatar_url: "whatever" + }, + "@huckleton:cadence.moe": { + display_name: "wa", + avatar_url: "whatever" + }, + "@_ooye_botrac4r:cadence.moe": { + display_name: "botrac4r", + avatar_url: "whatever" + }, + "@_ooye_bot:cadence.moe": { + display_name: "Out Of Your Element", + avatar_url: "whatever" + } + } + }) + }) + }) + } + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.mentions": { + user_ids: [ + "@huckleton:cadence.moe", + ] + }, + msgtype: "m.text", + body: "I wonder if [@huck](https://matrix.to/#/@huckleton:cadence.moe) saw this?", + format: "org.matrix.custom.html", + formatted_body: `I wonder if @huck saw this?` + }]) +}) + +test("message2event: written @mentions do not match in URLs", async t => { + const events = await messageToEvent({ + ...data.message.advanced_written_at_mention_for_matrix, + content: "the fucking around with pixel composer continues https://pub.mastodon.sleeping.town/@exa/116037641900024965" + }, data.guild.general, {}, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.mentions": {}, + msgtype: "m.text", + body: "the fucking around with pixel composer continues https://pub.mastodon.sleeping.town/@exa/116037641900024965", + format: "org.matrix.custom.html", + formatted_body: `the fucking around with pixel composer continues https://pub.mastodon.sleeping.town/@exa/116037641900024965` + }]) +}) + +test("message2event: entire message may match elaborate display name", async t => { + let called = 0 + const events = await messageToEvent({ + ...data.message.advanced_written_at_mention_for_matrix, + content: "@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆" + }, data.guild.general, {}, { + api: { + async getJoinedMembers(roomID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + return new Promise(resolve => { + setTimeout(() => { + resolve({ + joined: { + "@wa:cadence.moe": { + display_name: "Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆", + avatar_url: "whatever" + }, + "@huckleton:cadence.moe": { + display_name: "huck", + avatar_url: "whatever" + }, + "@_ooye_botrac4r:cadence.moe": { + display_name: "botrac4r", + avatar_url: "whatever" + }, + "@_ooye_bot:cadence.moe": { + display_name: "Out Of Your Element", + avatar_url: "whatever" + } + } + }) + }) + }) + } + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.mentions": { + user_ids: [ + "@wa:cadence.moe", + ] + }, + msgtype: "m.text", + body: "[@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆](https://matrix.to/#/@wa:cadence.moe)", + format: "org.matrix.custom.html", + formatted_body: `@Cadence, Maid of Creation, Eye of Clarity, Empress of Hope ☆` + }]) +}) + +test("message2event: spoilers are removed from plaintext body", async t => { + const events = await messageToEvent({ + content: "||**beatrice**||" + }) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.mentions": {}, + msgtype: "m.text", + body: "[spoiler]", + format: "org.matrix.custom.html", + formatted_body: `beatrice` + }]) +}) + test("message2event: very large attachment is linked instead of being uploaded", async t => { const events = await messageToEvent({ content: "hey", @@ -846,14 +1041,62 @@ test("message2event: very large attachment is linked instead of being uploaded", $type: "m.room.message", "m.mentions": {}, msgtype: "m.text", - body: "hey" - }, { + body: "hey\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", + format: "org.matrix.custom.html", + formatted_body: 'hey
BILLY BOB THE GREAT
📸 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.text", + }, + { + $type: "m.room.message", + body: "100km.gif", + external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1296237494987133070/100km.gif", + filename: "100km.gif", + info: { + h: 300, + mimetype: "image/gif", + size: 2965649, + w: 300, + }, + "m.mentions": {}, + msgtype: "m.image", + url: "mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh", + }, + { + $type: "m.room.message", + body: "» | ## This man" + + "\n» | " + + "\n» | ## This man is 100 km away from your house" + + "\n» | " + + "\n» | ### Distance away" + + "\n» | 99 km" + + "\n» | " + + "\n» | ### Distance away" + + "\n» | 98 km", + format: "org.matrix.custom.html", + formatted_body: "
", + "m.mentions": {}, + msgtype: "m.notice" + } + ]) +}) + +test("message2event: constructed forwarded text", async t => { + const events = await messageToEvent(data.message.constructed_forwarded_text, {}, {}, { + api: { + getEffectivePower: mockGetEffectivePower(), + async getJoinedMembers() { + return { + joined: { + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:matrix.org": {display_name: null, avatar_url: null} + } + } + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "[🔀 Forwarded from #amanda-spam]" + + "\n» What's cooking, good looking?", + format: "org.matrix.custom.html", + formatted_body: `🔀 Forwarded from amanda-spam [jump to room]` + + `This man
This man is 100 km away from your house
Distance away
99 kmDistance away
98 km
What's cooking, good looking?`, + "m.mentions": {}, + msgtype: "m.text", + }, + { + $type: "m.room.message", + body: "What's cooking everybody ‼️", + "m.mentions": {}, + msgtype: "m.text", + } + ]) +}) + + +test("message2event: don't scan forwarded messages for mentions", async t => { + const events = await messageToEvent(data.message.forwarded_dont_scan_for_mentions, {}, {}, {}) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "[🔀 Forwarded message]" + + "\n» 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", + format: "org.matrix.custom.html", + 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.text" + } + ]) +}) + +test("message2event: invite no details embed if no event", async t => { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { + snow: { + invite: { + getInvite: async () => ({...data.invite.irl, guild_scheduled_event: null}) + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "https://discord.gg/placeholder?event=1381190945646710824", + format: "org.matrix.custom.html", + formatted_body: "https://discord.gg/placeholder?event=1381190945646710824", + "m.mentions": {}, + msgtype: "m.text", + } + ]) +}) + +test("message2event: irl invite event renders embed", async t => { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { + snow: { + invite: { + getInvite: async () => data.invite.irl + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "https://discord.gg/placeholder?event=1381190945646710824", + format: "org.matrix.custom.html", + formatted_body: "https://discord.gg/placeholder?event=1381190945646710824", + "m.mentions": {}, + msgtype: "m.text", + }, + { + $type: "m.room.message", + msgtype: "m.notice", + body: `| Scheduled Event - 8 June at 10:00 pm NZT – 9 June at 12:00 am NZT` + + `\n| ## forest exploration` + + `\n| ` + + `\n| 📍 the dark forest`, + format: "org.matrix.custom.html", + formatted_body: `
`, + "m.mentions": {} + } + ]) +}) + +test("message2event: vc invite event renders embed", async t => { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381174024801095751"}, {}, {}, { + snow: { + invite: { + getInvite: async () => data.invite.vc + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "https://discord.gg/placeholder?event=1381174024801095751", + format: "org.matrix.custom.html", + formatted_body: "https://discord.gg/placeholder?event=1381174024801095751", + "m.mentions": {}, + msgtype: "m.text", + }, + { + $type: "m.room.message", + msgtype: "m.notice", + body: `| Scheduled Event - 9 June at 3:00 pm NZT` + + `\n| ## Cooking (Netrunners)` + + `\n| Short circuited brain interfaces actually just means your brain is medium rare, yum.` + + `\n| ` + + `\n| 🔊 Cooking`, + format: "org.matrix.custom.html", + formatted_body: `Scheduled Event - 8 June at 10:00 pm NZT – 9 June at 12:00 am NZT
` + + `forest exploration` + + `📍 the dark forest
`, + "m.mentions": {} + } + ]) +}) + +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}, + } + }) + }, + snow: { + invite: { + getInvite: async () => data.invite.known_vc + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "https://discord.gg/placeholder?event=1381174024801095751", + format: "org.matrix.custom.html", + formatted_body: "https://discord.gg/placeholder?event=1381174024801095751", + "m.mentions": {}, + msgtype: "m.text", + }, + { + $type: "m.room.message", + msgtype: "m.notice", + body: `| Scheduled Event - 9 June at 3:00 pm NZT` + + `\n| ## Cooking (Netrunners)` + + `\n| Short circuited brain interfaces actually just means your brain is medium rare, yum.` + + `\n| ` + + `\n| 🔊 Hey. - https://matrix.to/#/!FuDZhlOAtqswlyxzeR:cadence.moe?via=cadence.moe`, + format: "org.matrix.custom.html", + formatted_body: `Scheduled Event - 9 June at 3:00 pm NZT
` + + `Cooking (Netrunners)
Short circuited brain interfaces actually just means your brain is medium rare, yum.` + + `🔊 Cooking
`, + "m.mentions": {} + } + ]) +}) + +test("message2event: channel links are converted even inside lists (parser post-processer descends into list items)", async t => { + let called = 0 + const events = await messageToEvent({ + content: "1. Don't be a dick" + + "\n2. Follow rule number 1" + + "\n3. Follow Discord TOS" + + "\n4. Do **not** post NSFW content, shock content, suggestive content" + + "\n5. Please keep <#176333891320283136> professional and helpful, no random off-topic joking" + + "\nThis list will probably change in the future" + }, data.guild.general, {}, { + api: { + getEffectivePower: mockGetEffectivePower(), + getJoinedMembers(roomID) { + called++ + t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") + return { + joined: { + "@quadradical:federated.nexus": { + membership: "join", + display_name: "quadradical" + } + } + } + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "1. Don't be a dick" + + "\n2. Follow rule number 1" + + "\n3. Follow Discord TOS" + + "\n4. Do **not** post NSFW content, shock content, suggestive content" + + "\n5. Please keep #wonderland professional and helpful, no random off-topic joking" + + "\nThis list will probably change in the future", + format: "org.matrix.custom.html", + formatted_body: "Scheduled Event - 9 June at 3:00 pm NZT
` + + `Cooking (Netrunners)
Short circuited brain interfaces actually just means your brain is medium rare, yum.` + + `🔊 Hey. - Hey.
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]
${stackLines.join("\n")}${util.inspect(gatewayMessage.d, false, 4, false)}.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards repliedToContent = repliedToContent.replace(/(?:\n|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|
)+/g, " ") // Should all be on one line @@ -593,213 +771,226 @@ async function eventToMessage(event, guild, di) { return convertEmoji(mxcUrlMatch?.[1], titleTextMatch?.[1], false, false) }) repliedToContent = repliedToContent.replace(/<[^:>][^>]*>/g, "") // Completely strip all HTML tags and formatting. - repliedToContent = repliedToContent.replace(/\bhttps?:\/\/[^ )]*/g, "<$&>") repliedToContent = entities.decodeHTML5Strict(repliedToContent) // Remove entities like & " const contentPreviewChunks = chunk(repliedToContent, 50) if (contentPreviewChunks.length) { contentPreview = ": " + contentPreviewChunks[0] + contentPreview = contentPreview.replace(/\bhttps?:\/\/[^ )]*/g, "<$&>") if (contentPreviewChunks.length > 1) contentPreview = contentPreview.replace(/[,.']$/, "") + "..." } else { - console.log("Unable to generate reply preview for this replied-to event because we stripped all of it:", repliedToEvent) contentPreview = "" } } + const sender = repliedToEvent.sender + const authorMention = getUserOrProxyOwnerMention(sender) + if (authorMention) { + replyLine += authorMention + } else { + let senderName = select("member_cache", "displayname", {mxid: sender}).pluck().get() + if (!senderName) senderName = sender.match(/@([^:]*)/)?.[1] + if (senderName) replyLine += `**Ⓜ${senderName}**` + } replyLine = `-# > ${replyLine}${contentPreview}\n` })() - if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) { - let input = event.content.formatted_body - if (event.content.msgtype === "m.emote") { - input = `* ${displayName} ${input}` - } - - // Handling mentions of Discord users - input = input.replace(/("https:\/\/matrix.to\/#\/((?:@|%40)[^"]+)")>/g, (whole, attributeValue, mxid) => { - mxid = decodeURIComponent(mxid) - if (mxUtils.eventSenderIsFromDiscord(mxid)) { - // Handle mention of an OOYE sim user by their mxid - const id = select("sim", "user_id", {mxid}).pluck().get() - if (!id) return whole - return `${attributeValue} data-user-id="${id}">` - } else { - // Handle mention of a Matrix user by their mxid - // Check if this Matrix user is actually the sim user from another old bridge in the room? - const match = mxid.match(/[^:]*discord[^:]*_([0-9]{6,}):/) // try to match @_discord_123456, @_discordpuppet_123456, etc. - if (match) return `${attributeValue} data-user-id="${match[1]}">` - // Nope, just a real Matrix user. - return whole + if (shouldProcessTextEvent) { + if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) { + let input = event.content.formatted_body + if (event.content.msgtype === "m.emote") { + input = `* ${displayName} ${input}` } - }) - // Handling mentions of rooms and room-messages - input = await handleRoomOrMessageLinks(input, di) - - // Stripping colons after mentions - input = input.replace(/( data-user-id.*?<\/a>):?/g, "$1") - input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1") - - // Element adds a bunch of
before
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(root)
+ 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
- }
+ // 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)
+ // @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, "<$&>")
+ // 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")
+ // 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
+ // 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)
- }
- } else if (event.type === "m.room.message" && (event.content.msgtype === "m.file" || event.content.msgtype === "m.video" || event.content.msgtype === "m.audio" || event.content.msgtype === "m.image")) {
- content = ""
- const filename = event.content.filename || event.content.body
- // A written `event.content.body` will be bridged to Discord's image `description` which is like alt text.
- // Bridging as description rather than message content in order to match Matrix clients (Element, Neochat) which treat this as alt text or title text.
- const description = (event.content.body !== event.content.filename && event.content.filename && event.content.body) || undefined
- if ("url" in event.content) {
- // Unencrypted
- attachments.push({id: "0", description, filename})
- pendingFiles.push({name: filename, mxc: event.content.url})
- } else {
- // Encrypted
- assert.equal(event.content.file.key.alg, "A256CTR")
- attachments.push({id: "0", description, filename})
- pendingFiles.push({name: filename, mxc: event.content.file.url, key: event.content.file.key.k, iv: event.content.file.iv})
- }
- } else if (event.type === "m.sticker") {
- content = ""
- let filename = event.content.body
- if (event.type === "m.sticker") {
- let mimetype
- if (event.content.info?.mimetype?.includes("/")) {
- mimetype = event.content.info.mimetype
+ // SPRITE SHEET EMOJIS FEATURE:
+ content = await linkEndOfMessageSpriteSheet(content)
} else {
- const res = await di.api.getMedia(event.content.url, {method: "HEAD"})
- if (res.status === 200) {
- mimetype = res.headers.get("content-type")
+ // Looks like we're using the plaintext body!
+ content = event.content.body
+
+ if (event.content.msgtype === "m.emote") {
+ content = `* ${displayName} ${content}`
}
- if (!mimetype) throw new Error(`Server error ${res.status} or missing content-type while detecting sticker mimetype`)
+
+ 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)
}
- filename += "." + mimetype.split("/")[1]
}
- attachments.push({id: "0", filename})
- pendingFiles.push({name: filename, mxc: event.content.url})
}
content = displayNameRunoff + replyLine + content
// Split into 2000 character chunks
const chunks = chunk(content, 2000)
- /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]})[]} */
+ /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */
const messages = chunks.map(content => ({
content,
allowed_mentions: {
@@ -822,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 a97fd266..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({
@@ -559,7 +940,7 @@ test("event2message: lists are bridged correctly", async t => {
"transaction_id": "m1692967313951.441"
},
"event_id": "$l-xQPY5vNJo3SNxU9d8aOWNVD1glMslMyrp4M_JEF70",
- "room_id": "!BpMdOUkWWhFxmTrENV:cadence.moe"
+ "room_id": "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
@@ -662,7 +1043,7 @@ test("event2message: code block contents are formatted correctly and not escaped
formatted_body: "input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n_input_ = input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n
\ninput = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,
\n"
},
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
- room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
@@ -692,7 +1073,7 @@ test("event2message: code blocks use double backtick as delimiter when necessary
formatted_body: "backtick in ` the middle, backtick at the edge`"
},
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
- room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
@@ -722,7 +1103,7 @@ test("event2message: inline code is converted to code block if it contains both
formatted_body: "` one two ``"
},
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
- room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
@@ -752,7 +1133,7 @@ test("event2message: code blocks are uploaded as attachments instead if they con
formatted_body: 'So if you run code like thisSystem.out.println("```");
it should print a markdown formatted code block'
},
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
- room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
@@ -784,7 +1165,7 @@ test("event2message: code blocks are uploaded as attachments instead if they con
formatted_body: 'So if you run code like thisSystem.out.println("```");
it should print a markdown formatted code block'
},
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
- room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
@@ -821,7 +1202,7 @@ test("event2message: characters are encoded properly in code blocks", async t =>
+ '\n '
},
event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s",
- room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe"
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
ensureJoined: [],
@@ -902,7 +1283,7 @@ test("event2message: lists have appropriate line breaks", async t => {
'm.mentions': {},
msgtype: 'm.text'
},
- room_id: '!cBxtVRxDlZvSVhJXVK:cadence.moe',
+ room_id: '!TqlyQmifxGUggEmdBN:cadence.moe',
sender: '@Milan:tchncs.de',
type: 'm.room.message',
}),
@@ -943,7 +1324,7 @@ test("event2message: ordered list start attribute works", async t => {
'm.mentions': {},
msgtype: 'm.text'
},
- room_id: '!cBxtVRxDlZvSVhJXVK:cadence.moe',
+ room_id: '!TqlyQmifxGUggEmdBN:cadence.moe',
sender: '@Milan:tchncs.de',
type: 'm.room.message',
}),
@@ -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",
@@ -1088,7 +1469,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c
content: {
body: "> <@cadence:cadence.moe> I just checked in a fix that will probably work, can you try reproducing this on the latest `main` branch and see if I fixed it?\n\nwill try later (tomorrow if I don't forgor)",
format: "org.matrix.custom.html",
- formatted_body: "In reply to @cadence:cadence.moe
I just checked in a fix that will probably work, can you try reproducing this on the latest main branch and see if I fixed it?
will try later (tomorrow if I don't forgor)",
+ formatted_body: "In reply to @cadence:cadence.moe
I just checked in a fix that will probably work, can you try reproducing this on the latest main branch and see if I fixed it?
will try later (tomorrow if I don't forgor)",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0"
@@ -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",
@@ -1111,7 +1492,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c
"msgtype": "m.text",
"body": "> <@solonovamax:matrix.org> multipart messages will be deleted if the message is edited to require less space\n> \n> \n> steps to reproduce:\n> \n> 1. send a message that is longer than 2000 characters (discord character limit)\n> - bot will split message into two messages on discord\n> 2. edit message to be under 2000 characters (discord character limit)\n> - bot will delete one of the messages on discord, and then edit the other one to include the edited content\n> - the bot will *then* delete the message on matrix (presumably) because one of the messages on discord was deleted (by \n\nI just checked in a fix that will probably work, can you try reproducing this on the latest `main` branch and see if I fixed it?",
"format": "org.matrix.custom.html",
- "formatted_body": "In reply to @solonovamax:matrix.org
multipart messages will be deleted if the message is edited to require less space
\nsteps to reproduce:
\n\n- send a message that is longer than 2000 characters (discord character limit)
\n
\n\n- bot will split message into two messages on discord
\n
\n\n- edit message to be under 2000 characters (discord character limit)
\n
\n\n- bot will delete one of the messages on discord, and then edit the other one to include the edited content
\n- the bot will then delete the message on matrix (presumably) because one of the messages on discord was deleted (by
\n
\n
I just checked in a fix that will probably work, can you try reproducing this on the latest main branch and see if I fixed it?",
+ "formatted_body": "In reply to @solonovamax:matrix.org
multipart messages will be deleted if the message is edited to require less space
\nsteps to reproduce:
\n\n- send a message that is longer than 2000 characters (discord character limit)
\n
\n\n- bot will split message into two messages on discord
\n
\n\n- edit message to be under 2000 characters (discord character limit)
\n
\n\n- bot will delete one of the messages on discord, and then edit the other one to include the edited content
\n- the bot will then delete the message on matrix (presumably) because one of the messages on discord was deleted (by
\n
\n
I just checked in a fix that will probably work, can you try reproducing this on the latest main branch and see if I fixed it?",
"m.relates_to": {
"m.in_reply_to": {
"event_id": "$u4OD19vd2GETkOyhgFVla92oDKI4ojwBf2-JeVCG7EI"
@@ -1123,7 +1504,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c
"age": 19069564
},
"event_id": "$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0",
- "room_id": "!cBxtVRxDlZvSVhJXVK:cadence.moe"
+ "room_id": "!TqlyQmifxGUggEmdBN:cadence.moe"
})
},
snow: {
@@ -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",
@@ -2625,6 +3006,145 @@ test("event2message: rich reply to a deleted event", async t => {
)
})
+test("event2message: rich reply to a state event with no body", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ type: "m.room.message",
+ sender: "@ampflower:matrix.org",
+ content: {
+ msgtype: "m.text",
+ body: "> <@ampflower:matrix.org> changed the room topic\n\nnice room topic",
+ format: "org.matrix.custom.html",
+ formatted_body: "In reply to @ampflower:matrix.org changed the room topic
nice room topic",
+ "m.relates_to": {
+ "m.in_reply_to": {
+ event_id: "$f-noT-d-Eo_Xgpc05Ww89ErUXku4NwKWYGHLzWKo1kU"
+ }
+ }
+ },
+ event_id: "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8",
+ room_id: "!TqlyQmifxGUggEmdBN:cadence.moe"
+ }, data.guild.general, data.channel.general, {
+ api: {
+ getEvent: mockGetEvent(t, "!TqlyQmifxGUggEmdBN:cadence.moe", "$f-noT-d-Eo_Xgpc05Ww89ErUXku4NwKWYGHLzWKo1kU", {
+ type: "m.room.topic",
+ sender: "@ampflower:matrix.org",
+ content: {
+ topic: "you're cute"
+ },
+ user_id: "@ampflower:matrix.org"
+ })
+ }
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "Ampflower 🌺",
+ content: "-# > <:L1:1144820033948762203><:L2:1144820084079087647> (channel details edited)\nnice room topic",
+ avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/PRfhXYBTOalvgQYtmCLeUXko",
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+})
+
+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({
@@ -2827,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({
@@ -2957,6 +3518,133 @@ test("event2message: mentioning bridged rooms works (plaintext body)", async t =
)
})
+test("event2message: mentioning bridged rooms by alias works", async t => {
+ let called = 0
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ msgtype: "m.text",
+ body: "wrong body",
+ format: "org.matrix.custom.html",
+ formatted_body: `I'm just worm-farm testing channel mentions`
+ },
+ event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
+ origin_server_ts: 1688301929913,
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ type: "m.room.message",
+ unsigned: {
+ age: 405299
+ }
+ }, {}, {}, {
+ api: {
+ async getAlias(alias) {
+ called++
+ t.equal(alias, "#worm-farm:cadence.moe")
+ return "!BnKuBPCvyfOkhcUjEu:cadence.moe"
+ }
+ }
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "I'm just <#1100319550446252084> testing channel mentions",
+ avatar_url: undefined,
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+ t.equal(called, 1)
+})
+
+test("event2message: mentioning bridged rooms by alias works (plaintext body)", async t => {
+ let called = 0
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ msgtype: "m.text",
+ body: `I'm just https://matrix.to/#/#worm-farm:cadence.moe?via=cadence.moe testing channel mentions`
+ },
+ event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
+ origin_server_ts: 1688301929913,
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ type: "m.room.message",
+ unsigned: {
+ age: 405299
+ }
+ }, {}, {}, {
+ api: {
+ async getAlias(alias) {
+ called++
+ t.equal(alias, "#worm-farm:cadence.moe")
+ return "!BnKuBPCvyfOkhcUjEu:cadence.moe"
+ }
+ }
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "I'm just <#1100319550446252084> testing channel mentions",
+ avatar_url: undefined,
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+ t.equal(called, 1)
+})
+
+test("event2message: mentioning bridged rooms by alias skips the link when alias is unresolvable", async t => {
+ let called = 0
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ msgtype: "m.text",
+ body: `I'm just https://matrix.to/#/#worm-farm:cadence.moe?via=cadence.moe and https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe?via=cadence.moe testing channel mentions`
+ },
+ event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
+ origin_server_ts: 1688301929913,
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ type: "m.room.message",
+ unsigned: {
+ age: 405299
+ }
+ }, {}, {}, {
+ api: {
+ async getAlias(alias) {
+ called++
+ throw new MatrixServerError("Alias doesn't exist or something")
+ }
+ }
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "I'm just and <#1100319550446252084> testing channel mentions",
+ avatar_url: undefined,
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+ t.equal(called, 1)
+})
+
test("event2message: mentioning known bridged events works (plaintext body)", async t => {
t.deepEqual(
await eventToMessage({
@@ -3109,7 +3797,7 @@ test("event2message: mentioning unknown bridged events can approximate with time
unsigned: {
age: 405299
}
- }, {}, {
+ }, {}, {}, {
api: {
async getEvent(roomID, eventID) {
called++
@@ -3156,7 +3844,7 @@ test("event2message: mentioning events falls back to original link when server d
unsigned: {
age: 405299
}
- }, {}, {
+ }, {}, {}, {
api: {
async getEvent(roomID, eventID) {
called++
@@ -3202,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() {
@@ -3358,17 +4046,17 @@ test("event2message: caches the member if the member is not known", async t => {
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
- room_id: "!should_be_newly_cached:cadence.moe",
+ room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe",
sender: "@should_be_newly_cached:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
- }, {}, {
+ }, {}, {}, {
api: {
getStateEvent: async (roomID, type, stateKey) => {
called++
- t.equal(roomID, "!should_be_newly_cached:cadence.moe")
+ t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe")
t.equal(type, "m.room.member")
t.equal(stateKey, "@should_be_newly_cached:cadence.moe")
return {
@@ -3392,12 +4080,60 @@ test("event2message: caches the member if the member is not known", async t => {
}
)
- t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached:cadence.moe"}).all(), [
+ t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe"}).all(), [
{avatar_url: "mxc://cadence.moe/this_is_the_avatar", displayname: null, mxid: "@should_be_newly_cached:cadence.moe"}
])
t.equal(called, 1, "getStateEvent should be called once")
})
+test("event2message: does not cache the member if the room is not known", async t => {
+ let called = 0
+ t.deepEqual(
+ await eventToMessage({
+ content: {
+ body: "testing the member state cache",
+ msgtype: "m.text"
+ },
+ event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
+ origin_server_ts: 1688301929913,
+ room_id: "!not_real:cadence.moe",
+ sender: "@should_not_be_cached:cadence.moe",
+ type: "m.room.message",
+ unsigned: {
+ age: 405299
+ }
+ }, {}, {}, {
+ api: {
+ getStateEvent: async (roomID, type, stateKey) => {
+ called++
+ t.equal(roomID, "!not_real:cadence.moe")
+ t.equal(type, "m.room.member")
+ t.equal(stateKey, "@should_not_be_cached:cadence.moe")
+ return {
+ avatar_url: "mxc://cadence.moe/this_is_the_avatar"
+ }
+ }
+ }
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "should_not_be_cached",
+ content: "testing the member state cache",
+ avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/this_is_the_avatar",
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+
+ t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!not_real:cadence.moe"}).all(), [])
+ t.equal(called, 1, "getStateEvent should be called once")
+})
+
test("event2message: skips caching the member if the member does not exist, somehow", async t => {
let called = 0
t.deepEqual(
@@ -3414,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++
@@ -3453,17 +4189,17 @@ test("event2message: overly long usernames are shifted into the message content"
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
- room_id: "!should_be_newly_cached_2:cadence.moe",
+ room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe",
sender: "@should_be_newly_cached_2:cadence.moe",
type: "m.room.message",
unsigned: {
age: 405299
}
- }, {}, {
+ }, {}, {}, {
api: {
getStateEvent: async (roomID, type, stateKey) => {
called++
- t.equal(roomID, "!should_be_newly_cached_2:cadence.moe")
+ t.equal(roomID, "!cqeGDbPiMFAhLsqqqq:cadence.moe")
t.equal(type, "m.room.member")
t.equal(stateKey, "@should_be_newly_cached_2:cadence.moe")
return {
@@ -3486,7 +4222,7 @@ test("event2message: overly long usernames are shifted into the message content"
}]
}
)
- t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached_2:cadence.moe"}).all(), [
+ t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe"}).all(), [
{avatar_url: null, displayname: "I am BLACK I am WHITE I am SHORT I am LONG I am EVERYTHING YOU THINK IS IMPORTANT and I DON'T MATTER", mxid: "@should_be_newly_cached_2:cadence.moe"}
])
t.equal(called, 1, "getStateEvent should be called once")
@@ -3501,7 +4237,7 @@ test("event2message: overly long usernames are not treated specially when the ms
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
origin_server_ts: 1688301929913,
- room_id: "!should_be_newly_cached_2:cadence.moe",
+ room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe",
sender: "@should_be_newly_cached_2:cadence.moe",
type: "m.room.message",
unsigned: {
@@ -3549,7 +4285,7 @@ test("event2message: text attachments work", async t => {
username: "cadence [they]",
content: "",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
- attachments: [{id: "0", description: undefined, filename: "chiki-powerups.txt"}],
+ attachments: [{id: "0", filename: "chiki-powerups.txt"}],
pendingFiles: [{name: "chiki-powerups.txt", mxc: "mxc://cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH"}]
}]
}
@@ -3585,14 +4321,14 @@ test("event2message: image attachments work", async t => {
username: "cadence [they]",
content: "",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
- attachments: [{id: "0", description: undefined, filename: "cool cat.png"}],
+ attachments: [{id: "0", filename: "cool cat.png"}],
pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}]
}]
}
)
})
-test("event2message: image attachments can have a custom description", async t => {
+test("event2message: image attachments can have a plaintext caption", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
@@ -3619,10 +4355,62 @@ test("event2message: image attachments can have a custom description", async t =
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
- content: "",
+ content: "Cat emoji surrounded by pink hearts",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
- attachments: [{id: "0", description: "Cat emoji surrounded by pink hearts", filename: "cool cat.png"}],
- pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}]
+ attachments: [{id: "0", filename: "cool cat.png"}],
+ pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}],
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
+ }]
+ }
+ )
+})
+
+test("event2message: image attachments 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: 226689,
+ 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`",
+ avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
+ attachments: [{id: "0", filename: "5740.jpg"}],
+ pendingFiles: [{name: "5740.jpg", mxc: "mxc://thomcat.rocks/RTHsXmcMPXmuHqVNsnbKtRbh"}],
+ allowed_mentions: {
+ parse: ["users", "roles"]
+ }
}]
}
)
@@ -3671,7 +4459,7 @@ test("event2message: encrypted image attachments work", async t => {
username: "cadence [they]",
content: "",
avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
- attachments: [{id: "0", description: undefined, filename: "image.png"}],
+ attachments: [{id: "0", filename: "image.png"}],
pendingFiles: [{
name: "image.png",
mxc: "mxc://heyquark.com/LOGkUTlVFrqfiExlGZNgCJJX",
@@ -3683,6 +4471,251 @@ test("event2message: encrypted image attachments work", async t => {
)
})
+test("event2message: evil encrypted image attachment works", async t => {
+ t.deepEqual(
+ await eventToMessage({
+ sender: "@austin:tchncs.de",
+ type: "m.room.message",
+ content: {
+ body: "Screenshot 2025-06-29 at 13.36.46.png",
+ file: {
+ hashes: {
+ sha256: "Vh1apd8wSFu/BpUdQbIrKUzFB0Uu+l1octgZL+aVGTQ"
+ },
+ iv: "sd33K7pSZNMAAAAAAAAAAA",
+ key: {
+ alg: "A256CTR",
+ ext: true,
+ k: "-nyqk1eqI-g-ND59P9qHp310-Qyc2A5gSAYm1BxopSg",
+ key_ops: [
+ "encrypt",
+ "decrypt"
+ ],
+ kty: "oct"
+ },
+ url: "mxc://tchncs.de/eac5f83fa97cd74062daf75dfa04d6e5356897281939377544214085632",
+ v: "v2"
+ },
+ info: {
+ h: 682,
+ mimetype: "image/png",
+ "org.matrix.msc4230.is_animated": false,
+ size: 1813154,
+ thumbnail_file: {
+ hashes: {
+ sha256: "o3xykQwfsTUf5Y8qP5fjT7qBv5lAT3rtkmPpise5eQw"
+ },
+ iv: "SNxIZsJkju4AAAAAAAAAAA",
+ key: {
+ alg: "A256CTR",
+ ext: true,
+ k: "CcibYjzzSDexOWBbcBh_kCDiLibg8vUZthz5CnxV0es",
+ key_ops: [
+ "encrypt",
+ "decrypt"
+ ],
+ kty: "oct"
+ },
+ url: "mxc://tchncs.de/ecd811d913ed1b240ebfc81517a5de2c3a1e9d401939377537079574528",
+ v: "v2"
+ },
+ thumbnail_info: {
+ h: 600,
+ mimetype: "image/png",
+ size: 451773,
+ w: 507
+ },
+ thumbnail_url: null,
+ w: 577,
+ "xyz.amorgan.blurhash": "TqN1Ais=t1~qRjWFxURiWCM{ofof"
+ },
+ "m.mentions": {},
+ msgtype: "m.image",
+ url: null
+ },
+ event_id: "$UKMbzTlqlyLYN78utVEtiivABFvOe39nx5trHwqNmeQ",
+ room_id: "!iSyXgNxQcEuXoXpsSn:pussthecat.org"
+ }),
+ {
+ ensureJoined: [],
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "Austin Huang",
+ content: "",
+ avatar_url: "https://bridge.example.org/download/matrix/tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e",
+ attachments: [{id: "0", filename: "Screenshot 2025-06-29 at 13.36.46.png"}],
+ pendingFiles: [{
+ name: "Screenshot 2025-06-29 at 13.36.46.png",
+ mxc: "mxc://tchncs.de/eac5f83fa97cd74062daf75dfa04d6e5356897281939377544214085632",
+ key: "-nyqk1eqI-g-ND59P9qHp310-Qyc2A5gSAYm1BxopSg",
+ iv: "sd33K7pSZNMAAAAAAAAAAA"
+ }]
+ }]
+ }
+ )
+})
+
+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({
@@ -3735,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++
@@ -3778,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++
@@ -3942,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) {
@@ -3995,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) {
@@ -4039,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 */
@@ -4108,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")
@@ -4121,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"
+ }
+ }
}
}
}),
@@ -4141,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",
@@ -4154,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, "")
@@ -4167,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"
+ }
+ }
}
}
}),
@@ -4187,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",
@@ -4200,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, "")
@@ -4215,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"
+ }
+ }
}
}
}),
@@ -4264,102 +5332,158 @@ test("event2message: @room in the middle of a link is not converted", 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: table", 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: "contentCol 1 Col 2 Col 3 Apple Banana Cherry Aardvark Bee Crocodile Argon Boron Carbon
more content"
+ },
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A"
+ }),
+ {
+ messagesToDelete: [],
+ messagesToEdit: [],
+ messagesToSend: [{
+ username: "cadence [they]",
+ content: "content```"
+ + "\nCol 1 Col 2 Col 3 "
+ + "\n---------------------------"
+ + "\nApple Banana Cherry "
+ + "\nAardvark Bee Crocodile"
+ + "\nArgon Boron Carbon ```"
+ + "more content",
+ 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: 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 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 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: 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: "!maggESguZBqGBZtSnr: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: 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: []
+ }
+ )
+})
+
+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 c5386270..00000000
--- a/src/m2d/converters/utils.js
+++ /dev/null
@@ -1,243 +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 includes messages sent by the appservice's bot user, because that is what's used for webhooks
- // TODO: It would be nice if bridge system messages wouldn't trigger this check and could be bridged from matrix to discord, while webhook reflections would remain ignored...
- // TODO that only applies to the above todo: But you'd have to watch out for the /icon command, where the bridge bot would set the room avatar, and that shouldn't be reflected into the room a second time.
- 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?}
- */
-function getPublicUrlForMxc(mxc) {
- assert(hasher, "xxhash is not ready yet")
- const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
- if (!mediaParts) return null
-
- 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 301dcc43..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")
@@ -14,81 +16,187 @@ const sendEvent = sync.require("./actions/send-event")
const addReaction = sync.require("./actions/add-reaction")
/** @type {import("./actions/redact")} */
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
+/**
+ * This function is adapted from Evan Kaufman's fantastic work.
+ * The original function and my adapted function are both MIT licensed.
+ * @url https://github.com/EvanK/npm-loggable-error/
+ * @param {number} [depth]
+ * @returns {string}
+*/
+function stringifyErrorStack(err, depth = 0) {
+ let collapsed = " ".repeat(depth);
+ if (!(err instanceof Error)) {
+ return collapsed + err
+ }
+
+ // add full stack trace if one exists, otherwise convert to string
+ let stackLines = String(err?.stack ?? err).replace(/^/gm, " ".repeat(depth)).trim().split("\n")
+ let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/"))
+ if (cloudstormLine !== -1) {
+ stackLines = stackLines.slice(0, cloudstormLine - 2)
+ }
+ collapsed += stackLines.join("\n")
+
+ const props = Object.getOwnPropertyNames(err).filter(p => !["message", "stack"].includes(p))
+
+ // only break into object notation if we have additional props to dump
+ if (props.length) {
+ const dedent = " ".repeat(depth);
+ const indent = " ".repeat(depth + 2);
+
+ collapsed += " {\n";
+
+ // loop and print each (indented) prop name
+ for (let property of props) {
+ collapsed += `${indent}[${property}]: `;
+
+ // if another error object, stringify it too
+ if (err[property] instanceof Error) {
+ collapsed += stringifyErrorStack(err[property], depth + 2).trimStart();
+ }
+ // otherwise stringify as JSON
+ else {
+ collapsed += JSON.stringify(err[property]);
+ }
+
+ collapsed += "\n";
+ }
+
+ collapsed += `${dedent}}\n`;
+ }
+
+ 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
+ * @param {any} type
+ * @param {any} e
+ * @param {any} payload
+ */
+async function sendError(roomID, source, type, e, payload) {
+ if (source === "Matrix") {
+ printError(type, source, e, payload)
+ }
+
+ if (Date.now() - lastReportedEvent < 5000) return null
+ lastReportedEvent = Date.now()
+
+ let errorIntroLine = e.toString()
+ if (e.cause) {
+ errorIntroLine += ` (cause: ${e.cause})`
+ }
+
+ const builder = new utils.MatrixStringBuilder()
+
+ const cloudflareErrorTitle = errorIntroLine.match(/.*?discord\.com \| ([^<]*)<\/title>/s)?.[1]
+ if (cloudflareErrorTitle) {
+ builder.addLine(
+ `\u26a0 Matrix event not delivered to Discord. Discord might be down right now. Cloudflare error: ${cloudflareErrorTitle}`,
+ `\u26a0 Matrix event not delivered to Discord
Discord might be down right now. Cloudflare error: ${cloudflareErrorTitle}`
+ )
+ } else {
+ // What
+ const what = source === "Discord" ? "Bridged event from Discord not delivered" : "Matrix event not delivered to Discord"
+ builder.addLine(`\u26a0 ${what}`, `\u26a0 ${what}`)
+
+ // Who
+ builder.addLine(`Event type: ${type}`)
+
+ // Why
+ builder.addLine(errorIntroLine)
+
+ // Where
+ const stack = stringifyErrorStack(e)
+ builder.addLine(`Error trace:\n${stack}`, tag`Error trace
${stack}`)
+
+ // How
+ builder.addLine("", tag`Original payload
${util.inspect(payload, false, 4, false)}`)
+ }
+
+ // Send
+ try {
+ await api.sendEvent(roomID, "m.room.message", {
+ ...builder.get(),
+ "moe.cadence.ooye.error": {
+ source: source.toLowerCase(),
+ payload
+ },
+ "m.mentions": {
+ user_ids: ["@cadence:cadence.moe"]
+ }
+ })
+ } catch (e) {}
+}
+
function guard(type, fn) {
return async function(event, ...args) {
try {
return await fn(event, ...args)
} catch (e) {
- console.error("hit event-dispatcher's error handler with this exception:")
- console.error(e) // TODO: also log errors into a file or into the database, maybe use a library for this? or just wing it?
- console.error(`while handling this ${type} gateway event:`)
- console.dir(event, {depth: null})
-
- if (Date.now() - lastReportedEvent < 5000) return
- lastReportedEvent = Date.now()
-
- let stackLines = e.stack.split("\n")
- api.sendEvent(event.room_id, "m.room.message", {
- msgtype: "m.text",
- body: "\u26a0 Matrix event not delivered to Discord. See formatted content for full details.",
- format: "org.matrix.custom.html",
- formatted_body: "\u26a0 Matrix event not delivered to Discord"
- + `
Event type: ${type}`
- + `
${e.toString()}`
- + `
Error trace
`
- + `${stackLines.join("\n")}`
- + `Original payload
`
- + `${util.inspect(event, false, 4, false)}`,
- "moe.cadence.ooye.error": {
- source: "matrix",
- payload: event
- },
- "m.mentions": {
- user_ids: ["@cadence:cadence.moe"]
- }
- })
+ await sendError(event.room_id, "Matrix", type, e, event)
}
}
}
+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
- 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",
@@ -98,10 +206,13 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event)
+ if (!messageResponses.length) return
if (event.type === "m.room.message" && event.content.msgtype === "m.text") {
// @ts-ignore
await matrixCommandHandler.execute(event)
}
+ retrigger.messageFinishedBridging(event.event_id)
+ await api.ackEvent(event)
}))
sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
@@ -111,6 +222,56 @@ 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)
}))
sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction",
@@ -135,6 +296,7 @@ sync.addTemporaryListener(as, "type:m.room.redaction", guard("m.room.redaction",
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
await redact.handle(event)
+ await api.ackEvent(event)
}))
sync.addTemporaryListener(as, "type:m.room.avatar", guard("m.room.avatar",
@@ -159,25 +321,131 @@ async event => {
db.prepare("UPDATE channel_room SET nick = ? WHERE room_id = ?").run(name, event.room_id)
}))
+sync.addTemporaryListener(as, "type:m.room.topic", guard("m.room.topic",
+/**
+ * @param {Ty.Event.StateOuter} event
+ */
+async event => {
+ if (event.state_key !== "") return
+ if (utils.eventSenderIsFromDiscord(event.sender)) return
+ const customTopic = +!!event.content.topic
+ const row = select("channel_room", ["channel_id", "custom_topic"], {room_id: event.room_id}).get()
+ if (!row) return
+ if (customTopic !== row.custom_topic) db.prepare("UPDATE channel_room SET custom_topic = ? WHERE channel_id = ?").run(customTopic, row.channel_id)
+ if (!customTopic) await createRoom.syncRoom(row.channel_id) // if it's cleared we should reset it to whatever's on discord
+}))
+
+sync.addTemporaryListener(as, "type:m.room.pinned_events", guard("m.room.pinned_events",
+/**
+ * @param {Ty.Event.StateOuter} event
+ */
+async event => {
+ if (event.state_key !== "") return
+ if (utils.eventSenderIsFromDiscord(event.sender)) return
+ const pins = event.content.pinned
+ if (!Array.isArray(pins)) return
+ let prev = event.unsigned?.prev_content?.pinned
+ if (!Array.isArray(prev)) {
+ if (pins.length === 1) {
+ /*
+ In edge cases, prev_content isn't guaranteed to be provided by the server.
+ If prev_content is missing, we can't diff. Better safe than sorry: we'd like to ignore the change rather than wiping the whole channel's pins on Discord.
+ However, that would mean if the first ever pin came from Matrix-side, it would be ignored, because there would be no prev_content (it's the first pinned event!)
+ So to handle that edge case, we assume that if there's exactly 1 entry in `pinned`, this is the first ever pin and it should go through.
+ */
+ prev = []
+ } else {
+ return
+ }
+ }
+
+ await updatePins.updatePins(pins, prev)
+ await api.ackEvent(event)
+}))
+
+
+
+sync.addTemporaryListener(as, "type:m.space.child", guard("m.space.child",
+/**
+ * @param {Ty.Event.StateOuter} event
+ */
+async event => {
+ if (Array.isArray(event.content.via) && event.content.via.length) { // space child is being added
+ 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) {}
+ }
+}))
+
sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
/**
* @param {Ty.Event.StateOuter} event
*/
async event => {
if (event.state_key[0] !== "@") return
- if (utils.eventSenderIsFromDiscord(event.state_key)) return
+
+ if (event.state_key === utils.bot) {
+ const upgraded = await roomUpgrade.onBotMembership(event, api, createRoom)
+ if (upgraded) 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)
- } else {
- // Member is here
- db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?")
- .run(
- event.room_id, event.state_key,
- event.content.displayname || null, event.content.avatar_url || null,
- event.content.displayname || null, event.content.avatar_url || null
- )
+
+ // 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 {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 = ?, missing_profile = NULL").run(
+ event.room_id, event.state_key,
+ displayname, avatar_url, memberPower,
+ displayname, avatar_url, memberPower
+ )
}))
sync.addTemporaryListener(as, "type:m.room.power_levels", guard("m.room.power_levels",
@@ -186,9 +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/m2d/event-dispatcher.test.js b/src/m2d/event-dispatcher.test.js
new file mode 100644
index 00000000..de754da3
--- /dev/null
+++ b/src/m2d/event-dispatcher.test.js
@@ -0,0 +1,23 @@
+// @ts-check
+
+const {test} = require("supertape")
+const {stringifyErrorStack} = require("./event-dispatcher")
+
+test("stringify error stack: works", t => {
+ function a() {
+ const e = new Error("message", {cause: new Error("inner")})
+ // @ts-ignore
+ e.prop = 2.1
+ throw e
+ }
+ try {
+ a()
+ t.fail("shouldn't get here")
+ } catch (e) {
+ const str = stringifyErrorStack(e)
+ t.match(str, /^Error: message$/m)
+ t.match(str, /^ at a \(.*event-dispatcher\.test\.js/m)
+ t.match(str, /^ \[cause\]: Error: inner$/m)
+ t.match(str, /^ \[prop\]: 2.1$/m)
+ }
+})
diff --git a/src/matrix/api.js b/src/matrix/api.js
index 4866495e..87bbf0cc 100644
--- a/src/matrix/api.js
+++ b/src/matrix/api.js
@@ -2,11 +2,10 @@
const Ty = require("../types")
const assert = require("assert").strict
-
-const fetch = require("node-fetch").default
+const streamWeb = require("stream/web")
const passthrough = require("../passthrough")
-const { discord, sync, db } = passthrough
+const {sync, db, select} = passthrough
/** @type {import("./mreq")} */
const mreq = sync.require("./mreq")
/** @type {import("./txnid")} */
@@ -23,7 +22,11 @@ function path(p, mxid, otherParams = {}) {
const u = new URL(p, "http://localhost")
if (mxid) u.searchParams.set("user_id", mxid)
for (const entry of Object.entries(otherParams)) {
- if (entry[1] != undefined) {
+ if (Array.isArray(entry[1])) {
+ for (const element of entry[1]) {
+ u.searchParams.append(entry[0], element)
+ }
+ } else if (entry[1] != undefined) {
u.searchParams.set(entry[0], entry[1])
}
}
@@ -35,14 +38,22 @@ function path(p, mxid, otherParams = {}) {
/**
* @param {string} username
- * @returns {Promise}
*/
-function register(username) {
+async function register(username) {
console.log(`[api] register: ${username}`)
- return mreq.mreq("POST", "/client/v3/register", {
- type: "m.login.application_service",
- 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) {
+ if (e.errcode === "M_USER_IN_USE" || e.data?.error === "Internal server error") {
+ // "Internal server error" is the only OK error because older versions of Synapse say this if you try to register the same username twice.
+ } else {
+ throw e
+ }
+ }
}
/**
@@ -56,18 +67,29 @@ async function createRoom(content) {
}
/**
+ * @param {string} roomIDOrAlias
+ * @param {string?} [mxid]
+ * @param {string[]?} [via]
* @returns {Promise} room ID
*/
-async function joinRoom(roomIDOrAlias, mxid) {
+async function joinRoom(roomIDOrAlias, mxid, via) {
/** @type {Ty.R.RoomJoined} */
- const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid))
+ const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid, {via}), {})
return root.room_id
}
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) {
@@ -75,6 +97,16 @@ async function leaveRoom(roomID, mxid) {
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {})
}
+/**
+ * @param {string} roomID
+ * @param {string} reason
+ * @param {string} [mxid]
+ */
+async function leaveRoomWithReason(roomID, reason, mxid) {
+ console.log(`[api] leave: ${roomID}: ${mxid}, because ${reason}`)
+ await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {reason})
+}
+
/**
* @param {string} roomID
* @param {string} eventID
@@ -98,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`)
@@ -114,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
@@ -123,6 +259,17 @@ function getJoinedMembers(roomID) {
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`)
}
+/**
+ * "Get the list of members for this room." This includes joined, invited, knocked, left, and banned members unless a filter is provided.
+ * The endpoint also supports `at` and `not_membership` URL parameters, but they are not exposed in this wrapper yet.
+ * @param {string} roomID
+ * @param {"join" | "invite" | "knock" | "leave" | "ban"} [membership] The kind of membership to filter for. Only one choice allowed.
+ * @returns {Promise<{chunk: Ty.Event.Outer[]}>}
+ */
+function getMembers(roomID, membership) {
+ return mreq.mreq("GET", `/client/v3/rooms/${roomID}/members`, undefined, {membership})
+}
+
/**
* @param {string} roomID
* @param {{from?: string, limit?: any}} pagination
@@ -154,6 +301,23 @@ async function getFullHierarchy(roomID) {
return rooms
}
+/**
+ * Like `getFullHierarchy` but reveals a page at a time through an async iterator.
+ * @param {string} roomID
+ */
+async function* generateFullHierarchy(roomID) {
+ /** @type {string | undefined} */
+ let nextBatch = undefined
+ do {
+ /** @type {Ty.HierarchyPagination} */
+ const res = await getHierarchy(roomID, {from: nextBatch})
+ for (const room of res.rooms) {
+ yield room
+ }
+ nextBatch = res.next_batch
+ } while (nextBatch)
+}
+
/**
* @param {string} roomID
* @param {string} eventID
@@ -248,51 +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} power
+ * @param {string | null | undefined} avatar_url
+ * @param {boolean} [inhibitPropagate]
*/
-async function setUserPower(roomID, mxid, power) {
- 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 = await getStateEvent(roomID, "m.room.power_levels", "")
- powerLevels.users = powerLevels.users || {}
- if (power != null) {
- powerLevels.users[mxid] = power
+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 {
- delete powerLevels.users[mxid]
- }
- await sendState(roomID, "m.room.power_levels", "", powerLevels)
- return powerLevels
-}
-
-/**
- * Set a user's power level for a whole room hierarchy.
- * @param {string} roomID
- * @param {string} mxid
- * @param {number} power
- */
-async function setUserPowerCascade(roomID, mxid, power) {
- assert(roomID[0] === "!")
- assert(mxid[0] === "@")
- const rooms = await getFullHierarchy(roomID)
- 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))
}
}
@@ -314,18 +460,132 @@ 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 {fetch.RequestInit} [init]
+ * @param {RequestInit & {height?: number | string}} [init]
+ * @return {Promise}>}
*/
-function getMedia(mxc, init = {}) {
+async function getMedia(mxc, init = {}) {
const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/)
assert(mediaParts)
- return 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)
+ }
+ // @ts-ignore
+ return res
+}
+
+/**
+ * Updates the m.read receipt in roomID to point to eventID.
+ * This doesn't modify m.fully_read, which matches [the behaviour of matrix-bot-sdk.](https://github.com/element-hq/matrix-bot-sdk/blob/e72a4c498e00c6c339a791630c45d00a351f56a8/src/MatrixClient.ts#L1227)
+ * @param {string} roomID
+ * @param {string} eventID
+ * @param {string?} [mxid]
+ */
+async function sendReadReceipt(roomID, eventID, mxid) {
+ await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/receipt/m.read/${eventID}`, mxid), {})
+}
+
+/**
+ * Acknowledge an event as read by calling api.sendReadReceipt on it.
+ * @param {Ty.Event.Outer} event
+ * @param {string?} [mxid]
+ */
+async function ackEvent(event, mxid) {
+ await sendReadReceipt(event.room_id, event.event_id, mxid)
+}
+
+/**
+ * Resolve a room alias to a room ID.
+ * @param {string} alias
+ */
+async function getAlias(alias) {
+ /** @type {Ty.R.ResolvedRoom} */
+ const root = await mreq.mreq("GET", `/client/v3/directory/room/${encodeURIComponent(alias)}`)
+ return root.room_id
+}
+
+/**
+ * @param {string} type namespaced event type, e.g. m.direct
+ * @param {string} [mxid] you
+ * @returns the *content* of the account data "event"
+ */
+async function getAccountData(type, mxid) {
+ if (!mxid) mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
+ const root = await mreq.mreq("GET", `/client/v3/user/${mxid}/account_data/${type}`)
+ return root
+}
+
+/**
+ * @param {string} type namespaced event type, e.g. m.direct
+ * @param {any} content whatever you want
+ * @param {string} [mxid] you
+ */
+async function setAccountData(type, content, mxid) {
+ if (!mxid) mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
+ await mreq.mreq("PUT", `/client/v3/user/${mxid}/account_data/${type}`, content)
+}
+
+/**
+ * @param {{presence: "online" | "offline" | "unavailable", status_msg?: string}} data
+ * @param {string} mxid
+ */
+async function setPresence(data, mxid) {
+ await mreq.mreq("PUT", path(`/client/v3/presence/${mxid}/status`, mxid), data)
+}
+
+/**
+ * @param {string} mxid
+ * @returns {Promise<{displayname?: string, avatar_url?: string}>}
+ */
+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
@@ -334,13 +594,19 @@ module.exports.createRoom = createRoom
module.exports.joinRoom = joinRoom
module.exports.inviteToRoom = inviteToRoom
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
module.exports.getFullHierarchy = getFullHierarchy
+module.exports.generateFullHierarchy = generateFullHierarchy
module.exports.getRelations = getRelations
module.exports.getFullRelations = getFullRelations
module.exports.sendState = sendState
@@ -349,7 +615,14 @@ 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
+module.exports.ackEvent = ackEvent
+module.exports.getAlias = getAlias
+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/api.test.js b/src/matrix/api.test.js
index 82565ebe..da923858 100644
--- a/src/matrix/api.test.js
+++ b/src/matrix/api.test.js
@@ -24,3 +24,7 @@ test("api path: real world mxid", t => {
test("api path: extras number works", t => {
t.equal(path(`/client/v3/rooms/!example/timestamp_to_event`, null, {ts: 1687324651120}), "/client/v3/rooms/!example/timestamp_to_event?ts=1687324651120")
})
+
+test("api path: multiple via params", t => {
+ t.equal(path(`/client/v3/rooms/!example/join`, null, {via: ["cadence.moe", "matrix.org"], ts: 1687324651120}), "/client/v3/rooms/!example/join?via=cadence.moe&via=matrix.org&ts=1687324651120")
+})
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 f0ee29ac..7bc1fec5 100644
--- a/src/matrix/file.js
+++ b/src/matrix/file.js
@@ -1,8 +1,9 @@
// @ts-check
-const fetch = require("node-fetch").default
-
const passthrough = require("../passthrough")
+const {reg, writeRegistration} = require("./read-registration.js")
+const Ty = require("../types")
+
const {sync, db, select} = passthrough
/** @type {import("./mreq")} */
const mreq = sync.require("./mreq")
@@ -46,11 +47,8 @@ async function uploadDiscordFileToMxc(path) {
return existingFromDb
}
- // Download from Discord
- const promise = fetch(url, {}).then(/** @param {import("node-fetch").Response} res */ async res => {
- // Upload to Matrix
- const root = await module.exports._actuallyUploadDiscordFileToMxc(urlNoExpiry, res)
-
+ // Download from Discord and upload to Matrix
+ const promise = module.exports._actuallyUploadDiscordFileToMxc(url).then(root => {
// Store relationship in database
db.prepare("INSERT INTO file (discord_url, mxc_url) VALUES (?, ?)").run(urlNoExpiry, root.content_uri)
inflight.delete(urlNoExpiry)
@@ -62,15 +60,33 @@ async function uploadDiscordFileToMxc(path) {
return promise
}
-async function _actuallyUploadDiscordFileToMxc(url, res) {
- const body = res.body
- /** @type {import("../types").R.FileUploaded} */
- const root = await mreq.mreq("POST", "/media/v3/upload", body, {
- headers: {
- "Content-Type": res.headers.get("content-type")
+/**
+ * @param {string} url
+ * @returns {Promise}
+ */
+async function _actuallyUploadDiscordFileToMxc(url) {
+ const res = await fetch(url, {})
+ try {
+ /** @type {Ty.R.FileUploaded} */
+ const root = await mreq.mreq("POST", "/media/v3/upload", res.body, {
+ headers: {
+ "Content-Type": res.headers.get("content-type")
+ }
+ })
+ return root
+ } catch (e) {
+ if (e instanceof mreq.MatrixServerError && e.data.error?.includes("Content-Length") && !reg.ooye.content_length_workaround) {
+ reg.ooye.content_length_workaround = true
+ const root = await _actuallyUploadDiscordFileToMxc(url)
+ console.error("OOYE cannot stream uploads to Synapse. The `content_length_workaround` option"
+ + "\nhas been activated in registration.yaml, which works around the problem, but"
+ + "\nhalves the speed of bridging d->m files. A better way to resolve this problem"
+ + "\nis to run an nginx reverse proxy to Synapse and re-run OOYE setup.")
+ writeRegistration(reg)
+ return root
}
- })
- return root
+ throw e
+ }
}
function guildIcon(guild) {
@@ -82,29 +98,28 @@ function userAvatar(user) {
}
function memberAvatar(guildID, user, member) {
- if (!member.avatar) return userAvatar(user)
- return `/guilds/${guildID}/users/${user.id}/avatars/${member.avatar}.png?size=${IMAGE_SIZE}`
+ if (!member?.avatar) return userAvatar(user)
+ return `/guilds/${guildID}/users/${user.id}/avatars/${member?.avatar}.png?size=${IMAGE_SIZE}`
}
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([
- [1, {label: "PNG", ext: "png", mime: "image/png"}],
- [2, {label: "APNG", ext: "png", mime: "image/apng"}],
- [3, {label: "LOTTIE", ext: "json", mime: "lottie"}],
- [4, {label: "GIF", ext: "gif", mime: "image/gif"}]
+ [1, {label: "PNG", ext: "png", mime: "image/png", endpoint: "/stickers/"}],
+ [2, {label: "APNG", ext: "png", mime: "image/apng", endpoint: "/stickers/"}],
+ [3, {label: "LOTTIE", ext: "json", mime: "lottie", endpoint: "/stickers/"}],
+ [4, {label: "GIF", ext: "gif", mime: "image/gif", endpoint: "https://media.discordapp.net/stickers/"}]
])
/** @param {{id: string, format_type: number}} sticker */
function sticker(sticker) {
const format = stickerFormat.get(sticker.format_type)
if (!format) throw new Error(`No such format ${sticker.format_type} for sticker ${JSON.stringify(sticker)}`)
- const ext = format.ext
- return `/stickers/${sticker.id}.${ext}`
+ return `${format.endpoint}${sticker.id}.${format.ext}`
}
module.exports.DISCORD_IMAGES_BASE = DISCORD_IMAGES_BASE
diff --git a/src/matrix/kstate.js b/src/matrix/kstate.js
index 67bb0631..3648f2d7 100644
--- a/src/matrix/kstate.js
+++ b/src/matrix/kstate.js
@@ -8,6 +8,10 @@ const passthrough = require("../passthrough")
const {sync} = passthrough
/** @type {import("./file")} */
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) {
@@ -43,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)
@@ -53,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
}
@@ -79,10 +94,28 @@ 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 === "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]
}
} else if (key in actual) {
@@ -102,8 +135,45 @@ function diffKState(actual, target) {
return diff
}
+/* c8 ignore start */
+
+/**
+ * Async because it gets all room state from the homeserver.
+ * @param {string} roomID
+ * @param {[type: string, key: string][]} [limitToEvents]
+ */
+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)
+ }
+}
+
+/**
+ * @param {string} roomID
+ * @param {any} kstate
+ */
+async function applyKStateDiffToRoom(roomID, kstate) {
+ const events = await kstateToState(kstate)
+ return Promise.all(events.map(({type, state_key, content}) =>
+ api.sendState(roomID, type, state_key, content)
+ ))
+}
+
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
+module.exports.applyKStateDiffToRoom = applyKStateDiffToRoom
diff --git a/src/matrix/kstate.test.js b/src/matrix/kstate.test.js
index 0538450b..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
}
@@ -234,3 +261,190 @@ test("diffKState: kstate keys must contain a slash separator", t => {
, /does not contain a slash separator/)
t.pass()
})
+
+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: 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 7a35e124..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", {
@@ -224,7 +221,7 @@ const commands = [{
.png()
.toBuffer({resolveWithObject: true})
console.log(`uploading emoji ${resizeOutput.data.length} bytes to :${e.name}:`)
- const emoji = await discord.snow.guildAssets.createEmoji(guildID, {name: e.name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")})
+ await discord.snow.assets.createGuildEmoji(guildID, {name: e.name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")})
}
api.sendEvent(event.room_id, "m.room.message", {
...ctx,
@@ -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 4707ae68..bb59506f 100644
--- a/src/matrix/mreq.js
+++ b/src/matrix/mreq.js
@@ -1,12 +1,11 @@
// @ts-check
-const fetch = require("node-fetch").default
-const mixin = require("@cloudrac3r/mixin-deep")
const stream = require("stream")
-const getStream = require("get-stream")
+const streamWeb = require("stream/web")
+const {buffer} = require("stream/consumers")
+const mixin = require("@cloudrac3r/mixin-deep")
const {reg} = require("./read-registration.js")
-
const baseUrl = `${reg.ooye.server_origin}/_matrix`
class MatrixServerError extends Error {
@@ -19,41 +18,71 @@ class MatrixServerError extends Error {
}
}
+/**
+ * @param {undefined | string | object | streamWeb.ReadableStream | stream.Readable} body
+ * @returns {Promise}
+ */
+async function _convertBody(body) {
+ if (body == undefined || Object.is(body.constructor, Object)) {
+ return JSON.stringify(body) // almost every POST request is going to follow this one
+ } else if (body instanceof stream.Readable && reg.ooye.content_length_workaround) {
+ return await buffer(body) // content length workaround is set, so convert to buffer. the buffer consumer accepts node streams.
+ } else if (body instanceof stream.Readable) {
+ return stream.Readable.toWeb(body) // native fetch can only consume web streams
+ } else if (body instanceof streamWeb.ReadableStream && reg.ooye.content_length_workaround) {
+ return await buffer(body) // content lenght workaround is set, so convert to buffer. the buffer consumer accepts async iterables, which web streams are.
+ }
+ return 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
- * @param {any} [body]
+ * @param {string | object | streamWeb.ReadableStream | stream.Readable} [bodyIn]
* @param {any} [extra]
*/
-async function mreq(method, url, body, extra = {}) {
- if (body == undefined || Object.is(body.constructor, Object)) {
- body = JSON.stringify(body)
- } else if (body instanceof stream.Readable && reg.ooye.content_length_workaround) {
- body = await getStream.buffer(body)
- }
+async function mreq(method, url, bodyIn, extra = {}) {
+ const body = await _convertBody(bodyIn)
+ /** @type {RequestInit} */
const opts = mixin({
method,
body,
headers: {
Authorization: `Bearer ${reg.as_token}`
- }
+ },
+ ...(body && {duplex: "half"}), // https://github.com/octokit/request.js/pull/571/files
}, extra)
- // console.log(baseUrl + url, opts)
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) {
- if (root.error?.includes("Content-Length")) {
- console.error(`OOYE cannot stream uploads to Synapse. Please choose one of these workarounds:`
- + `\n * Run an nginx reverse proxy to Synapse, and point registration.yaml's`
- + `\n \`server_origin\` to nginx`
- + `\n * Set \`content_length_workaround: true\` in registration.yaml (this will`
- + `\n halve the speed of bridging d->m files)`)
- throw new Error("Synapse is not accepting stream uploads, see the message above.")
- }
- delete opts.headers.Authorization
+ delete opts.headers?.["Authorization"]
throw new MatrixServerError(root, {baseUrl, url, ...opts})
}
return root
@@ -78,6 +107,8 @@ 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
+module.exports._convertBody = _convertBody
diff --git a/src/matrix/mreq.test.js b/src/matrix/mreq.test.js
new file mode 100644
index 00000000..7ac343e5
--- /dev/null
+++ b/src/matrix/mreq.test.js
@@ -0,0 +1,47 @@
+// @ts-check
+
+const assert = require("assert")
+const stream = require("stream")
+const streamWeb = require("stream/web")
+const {buffer} = require("stream/consumers")
+const {test} = require("supertape")
+const {_convertBody} = require("./mreq")
+const {reg} = require("./read-registration")
+
+async function *generator() {
+ yield "a"
+ yield "b"
+}
+
+reg.ooye.content_length_workaround = false
+
+test("convert body: converts object to string", async t => {
+ t.equal(await _convertBody({a: "1"}), `{"a":"1"}`)
+})
+
+test("convert body: leaves undefined as undefined", async t => {
+ t.equal(await _convertBody(undefined), undefined)
+})
+
+test("convert body: leaves web readable as web readable", async t => {
+ const webReadable = stream.Readable.toWeb(stream.Readable.from(generator()))
+ t.equal(await _convertBody(webReadable), webReadable)
+})
+
+test("convert body: converts node readable to web readable (for native fetch upload)", async t => {
+ const readable = stream.Readable.from(generator())
+ const webReadable = await _convertBody(readable)
+ assert(webReadable instanceof streamWeb.ReadableStream)
+ t.deepEqual(await buffer(webReadable), Buffer.from("ab"))
+})
+
+test("convert body: converts node readable to buffer", async t => {
+ reg.ooye.content_length_workaround = true
+ const readable = stream.Readable.from(generator())
+ t.deepEqual(await _convertBody(readable), Buffer.from("ab"))
+})
+
+test("convert body: converts web readable to buffer", async t => {
+ const webReadable = stream.Readable.toWeb(stream.Readable.from(generator()))
+ t.deepEqual(await _convertBody(webReadable), Buffer.from("ab"))
+})
diff --git a/src/matrix/power.js b/src/matrix/power.js
index 3e613dd7..d323d178 100644
--- a/src/matrix/power.js
+++ b/src/matrix/power.js
@@ -3,7 +3,6 @@
const {db, from} = require("../passthrough")
const {reg} = require("./read-registration")
const ks = require("./kstate")
-const {applyKStateDiffToRoom, roomToKState} = require("../d2m/actions/create-room")
/** Apply global power level requests across ALL rooms where the member cache entry exists but the power level has not been applied yet. */
function _getAffectedRooms() {
@@ -23,9 +22,9 @@ async function applyPower() {
const rows = _getAffectedRooms()
for (const row of rows) {
- const kstate = await roomToKState(row.room_id)
+ const kstate = await ks.roomToKState(row.room_id)
const diff = ks.diffKState(kstate, {"m.room.power_levels/": {users: {[row.mxid]: row.power_level}}})
- await applyKStateDiffToRoom(row.room_id, diff)
+ await ks.applyKStateDiffToRoom(row.room_id, diff)
// There is a listener on m.room.power_levels to do this same update,
// but we update it here anyway since the homeserver does not always deliver the event round-trip.
db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(row.power_level, row.room_id, row.mxid)
diff --git a/src/matrix/power.test.js b/src/matrix/power.test.js
deleted file mode 100644
index 5423c4fa..00000000
--- a/src/matrix/power.test.js
+++ /dev/null
@@ -1,12 +0,0 @@
-// @ts-check
-
-const {test} = require("supertape")
-const power = require("./power")
-
-test("power: get affected rooms", t => {
- t.deepEqual(power._getAffectedRooms(), [{
- mxid: "@test_auto_invite:example.org",
- power_level: 100,
- room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
- }])
-})
diff --git a/src/matrix/read-registration.js b/src/matrix/read-registration.js
index 9fb05359..114bf756 100644
--- a/src/matrix/read-registration.js
+++ b/src/matrix/read-registration.js
@@ -9,9 +9,9 @@ const registrationFilePath = path.join(process.cwd(), "registration.yaml")
/** @param {import("../types").AppServiceRegistrationConfig} reg */
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
+ 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")
@@ -19,9 +19,10 @@ function checkRegistration(reg) {
assert.match(reg.url, /^https?:/, "url must start with http:// or https://")
}
+/* 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")
}
/**
@@ -52,10 +53,12 @@ function getTemplateRegistration(serverName) {
socket: 6693,
ooye: {
namespace_prefix,
+ server_name: serverName,
max_file_size: 5000000,
content_length_workaround: false,
include_user_id_in_mxid: false,
- invite: []
+ invite: [],
+ receive_presences: true
}
}
}
@@ -66,6 +69,8 @@ function readRegistration() {
try {
const content = fs.readFileSync(registrationFilePath, "utf8")
result = JSON.parse(content)
+ result.ooye.invite ||= []
+ /* c8 ignore next */
} catch (e) {}
return result
}
diff --git a/src/matrix/read-registration.test.js b/src/matrix/read-registration.test.js
index 80ac09f1..5fb3b55c 100644
--- a/src/matrix/read-registration.test.js
+++ b/src/matrix/read-registration.test.js
@@ -1,5 +1,8 @@
+// @ts-check
+
+const tryToCatch = require("try-to-catch")
const {test} = require("supertape")
-const {reg} = require("./read-registration")
+const {reg, checkRegistration, getTemplateRegistration} = require("./read-registration")
test("reg: has necessary parameters", t => {
const propertiesToCheck = ["sender_localpart", "id", "as_token", "ooye"]
@@ -8,3 +11,19 @@ test("reg: has necessary parameters", t => {
propertiesToCheck
)
})
+
+test("check: passes on sample", t => {
+ checkRegistration(reg)
+ t.pass("all assertions passed")
+})
+
+test("check: fails on template as template is missing some required values that are gathered during setup", t => {
+ let err
+ try {
+ // @ts-ignore
+ checkRegistration(getTemplateRegistration("cadence.moe"))
+ } catch (e) {
+ err = e
+ }
+ t.ok(err, "one of the assertions failed as expected")
+})
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/passthrough.js b/src/passthrough.js
index 378f5162..8eedfc49 100644
--- a/src/passthrough.js
+++ b/src/passthrough.js
@@ -4,7 +4,7 @@
* @typedef {Object} Passthrough
* @property {import("repl").REPLServer} repl
* @property {import("./d2m/discord-client")} discord
- * @property {import("heatsync").default} sync
+ * @property {import("heatsync")} sync
* @property {import("better-sqlite3/lib/database")} db
* @property {import("@cloudrac3r/in-your-element").AppService} as
* @property {import("./db/orm").from} from
diff --git a/src/stdin.js b/src/stdin.js
index 90513954..fea5fad5 100644
--- a/src/stdin.js
+++ b/src/stdin.js
@@ -5,7 +5,7 @@ const util = require("util")
const {addbot} = require("../addbot")
const passthrough = require("./passthrough")
-const {discord, sync, db} = passthrough
+const {discord, sync, db, select, from, as} = passthrough
const data = sync.require("../test/data")
const createSpace = sync.require("./d2m/actions/create-space")
@@ -19,19 +19,18 @@ const eventDispatcher = sync.require("./d2m/event-dispatcher")
const updatePins = sync.require("./d2m/actions/update-pins")
const speedbump = sync.require("./d2m/actions/speedbump")
const ks = sync.require("./matrix/kstate")
+const setPresence = sync.require("./d2m/actions/set-presence")
+const channelWebhook = sync.require("./m2d/actions/channel-webhook")
const guildID = "112760669178241024"
-const extraContext = {}
-
if (process.stdin.isTTY) {
- setImmediate(() => { // assign after since old extraContext data will get removed
+ setImmediate(() => {
if (!passthrough.repl) {
const cli = repl.start({ prompt: "", eval: customEval, writer: s => s })
- Object.assign(cli.context, extraContext, passthrough)
+ Object.assign(cli.context, passthrough)
passthrough.repl = cli
- } else {
- Object.assign(passthrough.repl.context, extraContext)
}
+ // @ts-ignore
sync.addTemporaryListener(passthrough.repl, "exit", () => process.exit())
})
}
@@ -60,9 +59,3 @@ async function customEval(input, _context, _filename, callback) {
return callback(null, util.inspect(e, false, 100, true))
}
}
-
-sync.events.once(__filename, () => {
- for (const key in extraContext) {
- delete passthrough.repl.context[key]
- }
-})
diff --git a/src/types.d.ts b/src/types.d.ts
index 62d9b30f..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
@@ -28,6 +30,11 @@ export type AppServiceRegistrationConfig = {
content_length_workaround: boolean
include_user_id_in_mxid: boolean
invite: string[]
+ discord_origin?: string
+ discord_cdn_origin?: string,
+ web_password: string
+ time_zone?: string
+ receive_presences: boolean
}
old_bridge?: {
as_token: string
@@ -55,10 +62,12 @@ export type InitialAppServiceRegistrationConfig = {
socket?: string | number,
ooye: {
namespace_prefix: string
- max_file_size: number,
- content_length_workaround: boolean,
- invite: string[],
+ server_name: string
+ max_file_size: number
+ content_length_workaround: boolean
+ invite: string[]
include_user_id_in_mxid: boolean
+ receive_presences: boolean
}
}
@@ -67,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
@@ -113,7 +129,7 @@ export namespace Event {
sender: string
content: T
origin_server_ts: number
- unsigned: any
+ unsigned?: any
event_id: string
}
@@ -129,19 +145,43 @@ export namespace Event {
}
}
- export type BaseStateEvent = {
+ export type StrippedChildStateEvent = {
type: string
- room_id: string
- sender: string
- content: any
state_key: string
+ sender: string
origin_server_ts: number
- unsigned: any
- event_id: string
- user_id: string
- age: number
- replaces_state: string
- prev_content?: any
+ 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 = {
@@ -163,9 +203,12 @@ export namespace Event {
export type M_Room_Message_File = {
msgtype: "m.file" | "m.image" | "m.video" | "m.audio"
body: string
+ format?: "org.matrix.custom.html"
+ formatted_body?: string
filename?: string
url: string
info?: any
+ "page.codeberg.everypizza.msc4193.spoiler"?: boolean
"m.relates_to"?: {
"m.in_reply_to": {
event_id: string
@@ -180,7 +223,10 @@ export namespace Event {
export type M_Room_Message_Encrypted_File = {
msgtype: "m.file" | "m.image" | "m.video" | "m.audio"
body: string
+ format?: "org.matrix.custom.html"
+ formatted_body?: string
filename?: string
+ "page.codeberg.everypizza.msc4193.spoiler"?: boolean
file: {
url: string
iv: string
@@ -225,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
@@ -232,7 +321,6 @@ export namespace Event {
}
export type M_Room_Avatar = {
- discord_path?: string
url?: string
}
@@ -240,6 +328,14 @@ export namespace Event {
name?: string
}
+ export type M_Room_Topic = {
+ topic?: string
+ }
+
+ export type M_Room_PinnedEvents = {
+ pinned: string[]
+ }
+
export type M_Power_Levels = {
/** The level required to ban a user. Defaults to 50 if unspecified. */
ban?: number,
@@ -270,6 +366,11 @@ export namespace Event {
users_default?: number
}
+ export type M_Space_Child = {
+ via?: string[]
+ suggested?: boolean
+ }
+
export type M_Reaction = {
"m.relates_to": {
rel_type: "m.annotation"
@@ -284,6 +385,11 @@ export namespace Event {
}> & {
redacts: string
}
+
+ export type M_Room_Tombstone = {
+ body: string
+ replacement_room: string
+ }
}
export namespace R {
@@ -323,20 +429,82 @@ export namespace R {
export type Hierarchy = {
avatar_url?: string
canonical_alias?: string
- children_state: {}
+ children_state: Event.StrippedChildStateEvent[]
guest_can_join: boolean
join_rule?: string
name?: string
+ topic?: string
num_joined_members: number
room_id: string
room_type?: string
}
+
+ export type ResolvedRoom = {
+ 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/auth.js b/src/web/auth.js
new file mode 100644
index 00000000..c14dcd8d
--- /dev/null
+++ b/src/web/auth.js
@@ -0,0 +1,33 @@
+// @ts-check
+
+const h3 = require("h3")
+const {db} = require("../passthrough")
+const {reg} = require("../matrix/read-registration")
+
+/**
+ * Combined guilds managed by Discord account + Matrix account.
+ * @param {h3.H3Event} event
+ * @returns {Promise>} guild IDs
+ */
+async function getManagedGuilds(event) {
+ const session = await useSession(event)
+ const managed = new Set(session.data.managedGuilds || [])
+ if (session.data.mxid) {
+ const matrixGuilds = db.prepare("SELECT guild_id FROM guild_space INNER JOIN member_cache ON space_id = room_id WHERE mxid = ? AND power_level >= 50").pluck().all(session.data.mxid)
+ for (const id of matrixGuilds) {
+ managed.add(id)
+ }
+ }
+ return managed
+}
+
+/**
+ * @param {h3.H3Event} event
+ * @returns {ReturnType>}
+ */
+function useSession(event) {
+ return h3.useSession(event, {password: reg.as_token, maxAge: 365 * 24 * 60 * 60})
+}
+
+module.exports.getManagedGuilds = getManagedGuilds
+module.exports.useSession = useSession
diff --git a/src/web/pug-sync.js b/src/web/pug-sync.js
index 32e7acc6..f87550df 100644
--- a/src/web/pug-sync.js
+++ b/src/web/pug-sync.js
@@ -3,12 +3,15 @@
const assert = require("assert/strict")
const fs = require("fs")
const {join} = require("path")
+const getRelativePath = require("get-relative-path")
const h3 = require("h3")
-const {defineEventHandler, defaultContentType, setResponseStatus, useSession, getQuery} = h3
+const {defineEventHandler, defaultContentType, setResponseStatus, getQuery} = h3
const {compileFile} = require("@cloudrac3r/pug")
+const pretty = process.argv.join(" ").includes("test")
-const {as} = require("../passthrough")
-const {reg} = require("../matrix/read-registration")
+const {sync} = require("../passthrough")
+/** @type {import("./auth")} */
+const auth = sync.require("./auth")
// Pug
@@ -28,20 +31,38 @@ 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, {})
+ const template = compileFile(path, {pretty})
pugCache.set(path, async (event, locals) => {
defaultContentType(event, "text/html; charset=utf-8")
- const session = await useSession(event, {password: reg.as_token})
+ const session = await auth.useSession(event)
+ const managed = await auth.getManagedGuilds(event)
+ const rel = (to, paramsObject) => {
+ let result = getRelativePath(event.path, to)
+ if (paramsObject) {
+ const params = new URLSearchParams(paramsObject)
+ result += "?" + params.toString()
+ }
+ return result
+ }
return template(Object.assign({},
getQuery(event), // Query parameters can be easily accessed on the top level but don't allow them to overwrite anything
globals, // Globals
locals, // Explicit locals overwrite globals in case we need to DI something
- {session} // Session is always session because it has to be trusted
+ {session, event, rel, managed} // These are assigned last so they overwrite everything else. It would be catastrophically bad if they can't be trusted.
))
})
+ /* c8 ignore start */
} catch (e) {
pugCache.set(path, async (event) => {
setResponseStatus(event, 500, "Internal Template Error")
@@ -49,6 +70,7 @@ function render(event, filename, locals) {
return e.toString()
})
}
+ /* c8 ignore stop */
}
if (!pugCache.has(path)) {
@@ -75,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 f92bf75d..a9e770b0 100644
--- a/src/web/pug/guild.pug
+++ b/src/web/pug/guild.pug
@@ -11,7 +11,9 @@ mixin badge-private
| Private
mixin discord(channel, radio=false)
- - let permissions = dUtils.getPermissions([], discord.guilds.get(channel.guild_id).roles, "", channel.permission_overwrites)
+ //- 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(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
@@ -45,115 +47,196 @@ mixin matrix(row, radio=false, badge="")
else
.s-user-card--link.fs-body1
a(href=`https://matrix.to/#/${row.room_id}`)= row.nick || row.name
+ if row.join_rule === "invite"
+ +badge-private
block body
- if !guild_id && session.data.managedGuilds
- .s-empty-state.wmx4.p48
- != icons.Spots.SpotEmptyXL
- p Select a server from the top right corner to continue.
- p If the server you're looking for isn't there, try #[a(href="/oauth?action=add") logging in again.]
+ .s-page-title.mb24
+ h1.s-page-title--header= guild.name
- else if !session.data.managedGuilds
- .s-empty-state.wmx4.p48
- != icons.Spots.SpotEmptyXL
- p You need to log in to manage your servers.
- a.s-btn.s-btn__icon.s-btn__filled(href="/oauth")
- != icons.Icons.IconDiscord
- = ` Log in with Discord`
+ .d-flex.g16(class="sm:fw-wrap")
+ .fl-grow1
+ h2.fs-headline1 Invite a Matrix user
- else if !discord.guilds.has(guild_id) || !session.data.managedGuilds || !session.data.managedGuilds.includes(guild_id)
- .s-empty-state.wmx4.p48
- != icons.Spots.SpotAlertXL
- p Either the selected server doesn't exist, or you don't have the Manage Server permission on Discord.
- p If you've checked your permissions, try #[a(href="/oauth") logging in again.]
+ form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-trigger="submit" hx-swap="none" hx-on::after-request="if (event.detail.successful) this.reset()" hx-disabled-elt="input, button" hx-indicator="#invite-button")
+ label.s-label(for="mxid") Matrix ID
+ input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)")
+ label.s-label(for="permissions") Permissions
+ .s-select
+ select#permissions(name="permissions")
+ option(value="default") Default
+ option(value="moderator") Moderator
+ option(value="admin") Admin
+ input(type="hidden" name="guild_id" value=guild_id)
+ .grid--row-start2
+ button.s-btn.s-btn__filled#invite-button Invite
+ div
+ .s-card.d-flex.ai-center.jc-center(style="min-width: 132px; min-height: 132px;")
+ 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
- else
- - let guild = discord.guilds.get(guild_id)
+ if space_id
+ h2.mt48.fs-headline1 Server settings
+ h3.mt32.fs-category Privacy level
+ span#privacy-level-loading
+ .s-card
+ form(hx-post=rel("/api/privacy-level") hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input")
+ input(type="hidden" name="guild_id" value=guild_id)
- .s-page-title.mb24
- h1.s-page-title--header= guild.name
+ .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr")
+ input(type="radio" name="privacy_level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2))
+ label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory")
+ != icons.Icons.IconPlusSm
+ != icons.Icons.IconInternationalSm
+ .fl-grow1 Directory
- .d-flex.g16
- .fl-grow1
- h2.fs-headline1 Invite a Matrix user
+ input(type="radio" name="privacy_level" value="link" id="privacy-level-link" checked=(privacy_level === 1))
+ label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link")
+ != icons.Icons.IconPlusSm
+ != icons.Icons.IconLinkSm
+ .fl-grow1 Link
- form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action="/api/invite" style="grid-template-rows: repeat(2, auto)")
- label.s-label(for="mxid") Matrix ID
- input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org")
- label.s-label(for="permissions") Permissions
- .s-select
- select#permissions(name="permissions")
- option(value="default") Default
- option(value="moderator") Moderator
- input(type="hidden" name="guild_id" value=guild_id)
- .grid--row-start2
- button.s-btn.s-btn__filled.htmx-indicator Invite
- div
- -
- let size = 105
- let src = new URL(`https://api.qrserver.com/v1/create-qr-code/?qzone=1&format=svg&size=${size}x${size}`)
- src.searchParams.set("data", `https://bridge.cadence.moe/invite?nonce=${nonce}`)
- img(width=size height=size src=src.toString())
+ input(type="radio" name="privacy_level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0))
+ label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite")
+ svg.svg-icon(width="14" height="14" viewBox="0 0 14 14")
+ != icons.Icons.IconLockSm
+ .fl-grow1 Invite
- h2.mt48.fs-headline1 Linked channels
+ p.s-description.m0 In-app direct invite from another user
+ p.s-description.m0 Shareable invite links, like Discord
+ p.s-description.m0 Publicly listed in directory, like Discord server discovery
- -
- 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)
+ h3.mt32.fs-category Features
+ .s-card.d-grid.px0.g16
+ form.d-flex.ai-center.g16
+ #url-preview-loading.p8
+ - let value = !!select("guild_space", "url_preview", {guild_id}).pluck().get()
+ input(type="hidden" name="guild_id" value=guild_id)
+ input.s-toggle-switch#url-preview(name="url_preview" type="checkbox" hx-post=rel("/api/url-preview") hx-indicator="#url-preview-loading" hx-disabled-elt="this" checked=value autocomplete="off")
+ label.s-label.fl-grow1(for="url-preview")
+ | Show Discord's URL previews on Matrix
+ p.s-description Shows info about links posted to chat. Discord's previews are generally better quality than Synapse's, especially for social media and videos.
- 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})).filter(c => c.channel)
- let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id)
- linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel) - getPosition(b.channel))
+ form.d-flex.ai-center.g16
+ #presence-loading.p8
+ - value = !!select("guild_space", "presence", {guild_id}).pluck().get()
+ input(type="hidden" name="guild_id" value=guild_id)
+ input.s-toggle-switch#presence(name="presence" type="checkbox" hx-post=rel("/api/presence") hx-indicator="#presence-loading" hx-disabled-elt="this" checked=value autocomplete="off")
+ label.s-label(for="presence")
+ | Show online statuses on Matrix
+ p.s-description This might cause lag on really big Discord servers.
- let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c))
- let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)).filter(c => [0, 5].includes(c.type))
- unlinkedChannels.sort((a, b) => getPosition(a) - getPosition(b))
+ 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
+
+ h3.mt32.fs-category Linked channels
.s-card.bs-sm.p0
- .s-table-container
+ form.s-table-container(method="post" action=rel("/api/unlink"))
+ input(type="hidden" name="guild_id" value=guild_id)
table.s-table.s-table__bx-simple
each row in linkedChannelsWithDetails
tr
td.w40: +discord(row.channel)
- td.p2: button.s-btn.s-btn__muted.s-btn__xs!= icons.Icons.IconLinkSm
+ td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" cx-prevent-default hx-post=rel("/api/unlink") hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources." value=row.channel.id hx-indicator="this" hx-disabled-elt="this")!= icons.Icons.IconLinkSm
td: +matrix(row)
else
tr
td(colspan="3")
.s-empty-state No channels linked between Discord and Matrix yet...
- h3.mt32.fs-category Auto-create
- .s-card
- form.d-flex.ai-center.g8
- label.s-label.fl-grow1(for="autocreate")
- | Create new Matrix rooms automatically
- p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in.
- - let value = select("guild_active", "autocreate", {guild_id}).pluck().get()
- input(type="hidden" name="guild_id" value=guild_id)
- input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value)
- .is-loading#autocreate-loading
+ h3.fs-category.mt32 Auto-create
+ .s-card.d-grid.px0
+ form.d-flex.ai-center.g16
+ #autocreate-loading.p8
+ - let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get()
+ input(type="hidden" name="guild_id" value=guild_id)
+ input.s-toggle-switch#autocreate(name="autocreate" type="checkbox" hx-post=rel("/api/autocreate") hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value autocomplete="off")
+ label.s-label.fl-grow1(for="autocreate")
+ | Create new Matrix rooms automatically
+ p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in.
+ if space_id
h3.mt32.fs-category Manually link channels
- form.d-flex.g16.ai-start(method="post" action="/api/link")
+ form.d-flex.g16.ai-start(hx-post=rel("/api/link") hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button")
.fl-grow2.s-btn-group.fd-column.w40
each channel in unlinkedChannels
- input.s-btn--radio(type="radio" name="discord" id=channel.id value=channel.id)
+ input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id)
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement")
else
.s-empty-state.p8 All Discord channels are linked.
.fl-grow1.s-btn-group.fd-column.w30
- .s-empty-state.p8 I don't know how to get the Matrix room list yet...
+ each room in unlinkedRooms
+ input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id)
+ label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
+ +matrix(room, true)
+ else
+ .s-empty-state.p8 All Matrix rooms are linked.
+ input(type="hidden" name="guild_id" value=guild_id)
div
- button.s-btn.s-btn__icon.s-btn__filled
- != icons.Icons.IconLink
- = ` Connect`
+ button.s-btn.s-btn__icon.s-btn__filled#link-button
+ != icons.Icons.IconMerge
+ = ` Link`
+
+ 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
+ div
+ h3.mt24 Channels
+ p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked.
+ div
+ h3.mt24 Rooms
+ p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked.
+ div
+ h3.mt24 Unavailable channels: Deleted from Discord
+ .s-card.p0
+ ul.my8.ml24
+ each row in removedUncachedChannels
+ li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.nick || row.name
+ h3.mt24 Unavailable channels: Wrong type
+ .s-card.p0
+ ul.my8.ml24
+ each row in removedWrongTypeChannels
+ li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`) (#{row.type}) #{row.name}
+ h3.mt24 Unavailable channels: Discord bot can't access
+ .s-card.p0
+ ul.my8.ml24
+ each row in removedPrivateChannels
+ li: a(href=`https://discord.com/channels/${guild_id}/${row.id}`)= row.name
+ div- // Rooms
+ h3.mt24 Unavailable rooms: Already linked
+ .s-card.p0
+ ul.my8.ml24
+ each row in removedLinkedRooms
+ li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
+ h3.mt24 Unavailable rooms: Wrong type
+ .s-card.p0
+ ul.my8.ml24
+ each row in removedWrongTypeRooms
+ li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name}
+ h3.mt24 Unavailable rooms: Archived thread
+ .s-card.p0
+ ul.my8.ml24
+ each row in removedArchivedThreadRooms
+ li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
diff --git a/src/web/pug/guild_access_denied.pug b/src/web/pug/guild_access_denied.pug
new file mode 100644
index 00000000..42fea7bf
--- /dev/null
+++ b/src/web/pug/guild_access_denied.pug
@@ -0,0 +1,36 @@
+extends includes/template.pug
+
+block body
+ if !session.data.userID
+ .s-empty-state.wmx4.p48
+ != icons.Spots.SpotEmptyXL
+ p You need to log in to manage your servers.
+ .d-flex.jc-center.g8
+ a.s-btn.s-btn__icon.s-btn__featured.s-btn__filled(href=rel("/oauth"))
+ != icons.Icons.IconDiscord
+ = ` Log in with Discord`
+ a.s-btn.s-btn__icon.s-btn__matrix.s-btn__filled(href=rel("/log-in-with-matrix"))
+ != icons.Icons.IconSpeechBubble
+ = ` Log in with Matrix`
+
+ else if !guild_id
+ .s-empty-state.wmx4.p48
+ != icons.Spots.SpotEmptyXL
+ p Select a server from the top right corner to continue.
+ p If the server you're looking for isn't there, try #[a(href=rel("/oauth?action=add")) logging in again.]
+
+ else if !discord.guilds.has(guild_id) || !managed.has(guild_id)
+ .s-empty-state.wmx4.p48
+ != icons.Spots.SpotAlertXL
+ p Either the selected server doesn't exist, or you don't have the Manage Server permission on Discord.
+ p If you've checked your permissions, try #[a(href=rel("/oauth")) logging in again.]
+
+ else if !row
+ .s-empty-state.wmx4.p48
+ != icons.Spots.SpotAlertXL
+ p Please add the bot to your server using the buttons on the home page.
+
+ else
+ .s-empty-state.wmx4.p48
+ != icons.Spots.SpotAlertXL
+ p Access denied.
diff --git a/src/web/pug/guild_not_linked.pug b/src/web/pug/guild_not_linked.pug
new file mode 100644
index 00000000..04d2dae3
--- /dev/null
+++ b/src/web/pug/guild_not_linked.pug
@@ -0,0 +1,64 @@
+extends includes/template.pug
+
+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) alt="")
+ else
+ .s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= space.name[0]
+ .s-user-card--info.ai-start
+ strong= space.name
+ if space.topic
+ ul.s-user-card--awards
+ li= space.topic
+
+block body
+ .s-notice.s-notice__info.d-flex.g16
+ div
+ != icons.Icons.IconInfo
+ div
+ - const self = `@${reg.sender_localpart}:${reg.ooye.server_name}`
+ strong You picked self-service mode
+ .mt4 To complete setup, you need to manually choose a Matrix space to link with #[strong= guild.name].
+ .mt4 On Matrix, invite #[code.s-code-block: a.fc-black.s-link(href=`https://matrix.to/#/${self}` target="_blank")= self] to a space. Then you can pick the space on this page.
+
+ h3.mt32.fs-category Choose a space
+
+ form.s-card.bs-sm.p0.s-table-container.bar-md(method="post" action=rel("/api/link-space"))
+ input(type="hidden" name="guild_id" value=guild_id)
+ table.s-table.s-table__bx-simple
+ each space in spaces
+ tr
+ td.p0: +space(space)
+ td: button.s-btn(name="space_id" value=space.room_id hx-post=rel("/api/link-space") hx-trigger="click" hx-disabled-elt="this") Link with this space
+ else
+ if session.data.mxid
+ tr
+ td.p16 Invite the bridge to a space, and the space will show up here.
+ else
+ tr
+ td.d-flex.ai-center.pl16.g16
+ | 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 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
+ | 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__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 1ad787da..8b865331 100644
--- a/src/web/pug/home.pug
+++ b/src/web/pug/home.pug
@@ -1,24 +1,58 @@
extends includes/template.pug
block body
- .s-page-title.mb24
- h1.s-page-title--header Bridge a Discord server
+ - let locked = reg.ooye.web_password && reg.ooye.web_password !== session.data.password
- .d-grid.grid__2.g24
- .s-card.bs-md.d-flex.fd-column
- h2 Easy mode
- p Add the bot to your Discord server.
- p It will automatically create new Matrix rooms for you.
- .fl-grow1
- a.s-btn.s-btn__filled.s-btn__icon(href="/oauth?action=add")
- != icons.Icons.IconPlus
- = ` Add to server`
- .s-card.bs-md.d-flex.fd-column
- h2 Self-service
- p OOYE will link an existing Discord server and Matrix space together.
- p Choose this option if you already have a community set up on Matrix.
- p Or, choose this if you're migrating from a different bridge.
- .fl-grow1
- a.s-btn.s-btn__outlined.s-btn__icon(href="/oauth?action=add-self-service")
- != icons.Icons.IconUnorderedList
- = ` Set up self-service`
+ if locked
+ aside.s-notice.s-notice__warning.p8
+ .d-flex.flex__center.jc-space-between.s-banner--container.g8(class="md:fw-wrap")
+ .d-flex.ai-center.g8
+ .flex--item!= icons.Icons.IconLock
+ p.m0 Private instance. You need the password to use this instance of Out Of Your Element.
+ form(method="post" action=rel("/api/password"))
+ input.s-input(placeholder="Enter password" name="password")
+
+ .h32
+
+ .s-page-title.mb24
+ h1.s-page-title--header Out Of Your Element
+
+ else
+ .s-page-title.mb24
+ h1.s-page-title--header Bridge a Discord server
+
+ .d-grid.g24.grid__2.mb24(class="sm:grid__1")
+ .s-card.bs-md.d-flex.fd-column
+ h2 Easy mode
+ p Add the bot to your Discord server.
+ p It will automatically create new Matrix rooms for you.
+ .fl-grow1
+ a.s-btn.s-btn__filled.s-btn__icon(href=rel("/oauth?action=add"))
+ != icons.Icons.IconPlus
+ = ` Add to server`
+ .s-card.bs-md.d-flex.fd-column
+ h2 Self-service
+ p OOYE will link an existing Discord server and Matrix space together.
+ p Choose this option if you already have a community set up on Matrix.
+ p Or, choose this if you're migrating from a different bridge.
+ .fl-grow1
+ a.s-btn.s-btn__outlined.s-btn__icon(href=rel("/oauth?action=add-self-service"))
+ != icons.Icons.IconUnorderedList
+ = ` Set up self-service`
+
+ .s-prose
+ 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
+ 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.]
diff --git a/src/web/pug/includes/hash.svg b/src/web/pug/includes/hash.svg
index 0f6fdd7c..461f2dc1 100644
--- a/src/web/pug/includes/hash.svg
+++ b/src/web/pug/includes/hash.svg
@@ -1,46 +1 @@
-
-
+
diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug
index c117056c..9fe80aad 100644
--- a/src/web/pug/includes/template.pug
+++ b/src/web/pug/includes/template.pug
@@ -1,66 +1,173 @@
-mixin guild(guild)
- 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`)
- 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
-
-doctype html
-html(lang="en")
- head
- title Out Of Your Element
- link(rel="stylesheet" type="text/css" href="/static/stacks.min.css")
-
- meta(name="htmx-config" content='{"indicatorClass":"is-loading"}')
- style.
- .themed {
- --theme-base-primary-color-h: 266;
- --theme-base-primary-color-s: 53%;
- --theme-base-primary-color-l: 63%;
- --theme-dark-primary-color-h: 266;
- --theme-dark-primary-color-s: 53%;
- --theme-dark-primary-color-l: 63%;
- }
- body.themed.theme-system
- header.s-topbar
- .s-topbar--skip-link(href="#content") Skip to main content
- .s-topbar--container.wmx9
- a.s-topbar--logo(href="/")
- img.s-avatar.s-avatar__32(src="/icon.png")
- nav.s-topbar--navigation
- ul.s-topbar--content
- li.ps-relative
- if !session.data.managedGuilds || session.data.managedGuilds.length === 0
- a.s-btn.s-btn__icon.as-center(href="/oauth")
- != icons.Icons.IconDiscord
- = ` Log in`
- else if guild_id && session.data.managedGuilds.includes(guild_id) && discord.guilds.has(guild_id)
- button.s-topbar--item.s-btn.s-btn__muted.s-user-card(popovertarget="guilds")
- +guild(discord.guilds.get(guild_id))
- else if session.data.managedGuilds
- button.s-topbar--item.s-btn.s-btn__muted.s-btn__dropdown.pr24.s-user-card.s-label(popovertarget="guilds")
- | Your servers
- #guilds(popover data-popper-placement="bottom" style="display: revert; width: revert;").s-popover.overflow-visible
- .s-popover--arrow.s-popover--arrow__tc
- .s-popover--content.overflow-y-auto.overflow-x-hidden
- ul.s-menu(role="menu")
- each guild in (session.data.managedGuilds || []).map(id => discord.guilds.get(id)).filter(g => g)
- li(role="menuitem")
- a.s-topbar--item.s-user-card.d-flex.p4(href=`/guild?guild_id=${guild.id}`)
- +guild(guild)
- .mx-auto.w100.wmx9.py24#content
- block body
- script.
- document.querySelectorAll("[popovertarget]").forEach(e => {
- e.addEventListener("click", () => {
- const rect = e.getBoundingClientRect()
- const t = `:popover-open { position: absolute; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }`
- // console.log(t)
- document.styleSheets[0].insertRule(t)
- })
- })
- script(src="/static/htmx.min.js")
+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` 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
+ 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.
+ :root {
+ --#{name}-h: #{h};
+ --#{name}-s: #{s};
+ --#{name}-l: #{l};
+ --#{name}: var(--#{name}-400);
+ --#{name}-100: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(70%, calc(var(--#{name}-l) + 50 * 1%), 95%));
+ --#{name}-200: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(55%, calc(var(--#{name}-l) + 35 * 1%), 90%));
+ --#{name}-300: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(35%, calc(var(--#{name}-l) + 15 * 1%), 75%));
+ --#{name}-400: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(20%, calc(var(--#{name}-l) + 0 * 1%), 60%));
+ --#{name}-500: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(15%, calc(var(--#{name}-l) + -14 * 1%), 45%));
+ --#{name}-600: hsl(var(--#{name}-h), calc(var(--#{name}-s) + 0 * 1%), clamp(5%, calc(var(--#{name}-l) + -26 * 1%), 30%));
+ }
+
+mixin define-themed-button(name, theme)
+ style.
+ .s-btn.s-btn__#{name} {
+ --_bu-bg-active: var(--#{theme}-300);
+ --_bu-bg-hover: var(--#{theme}-200);
+ --_bu-bg-selected: var(--#{theme}-300);
+ --_bu-fc: var(--#{theme}-500);
+ --_bu-fc-active: var(--_bu-fc);
+ --_bu-fc-hover: var(--#{theme}-500);
+ --_bu-fc-selected: var(--#{theme}-600);
+ --_bu-filled-bc: transparent;
+ --_bu-filled-bc-selected: var(--_bu-filled-bc);
+ --_bu-filled-bg: var(--#{theme}-400);
+ --_bu-filled-bg-active: var(--#{theme}-500);
+ --_bu-filled-bg-hover: var(--#{theme}-500);
+ --_bu-filled-bg-selected: var(--#{theme}-600);
+ --_bu-filled-fc: var(--white);
+ --_bu-filled-fc-active: var(--_bu-filled-fc);
+ --_bu-filled-fc-hover: var(--_bu-filled-fc);
+ --_bu-filled-fc-selected: var(--_bu-filled-fc);
+ --_bu-outlined-bc: var(--#{theme}-400);
+ --_bu-outlined-bc-selected: var(--#{theme}-500);
+ --_bu-outlined-bg-selected: var(--_bu-bg-selected);
+ --_bu-outlined-fc-selected: var(--_bu-fc-selected);
+ --_bu-number-fc: var(--white);
+ --_bu-number-fc-filled: var(--#{theme});
+ }
+
+doctype html
+html(lang="en")
+ head
+ 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.
+ .s-prose a {
+ text-decoration: underline;
+ }
+ .themed {
+ --theme-base-primary-color-h: 266;
+ --theme-base-primary-color-s: 53%;
+ --theme-base-primary-color-l: 63%;
+ --theme-dark-primary-color-h: 266;
+ --theme-dark-primary-color-s: 53%;
+ --theme-dark-primary-color-l: 63%;
+ }
+ .s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental input[type="radio"]:checked ~ label:not(.s-toggle-switch--label-off) {
+ --_ts-multiple-bg: var(--green-400);
+ --_ts-multiple-fc: var(--white);
+ }
+ .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
+ 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") alt="")
+ nav.s-topbar--navigation
+ ul.s-topbar--content
+ li.ps-relative.g8
+ if !session.data.mxid
+ a.s-btn.s-btn__icon.s-btn__matrix.s-btn__outlined.as-center(href=rel("/log-in-with-matrix"))
+ != icons.Icons.IconSpeechBubble
+ = ` Log in`
+ span(class="sm:d-none")= ` with Matrix`
+ if !session.data.userID
+ a.s-btn.s-btn__icon.s-btn__featured.s-btn__outlined.as-center(href=rel("/oauth"))
+ != icons.Icons.IconDiscord
+ = ` Log in`
+ span(class="sm:d-none")= ` with Discord`
+ if guild_id && managed.has(guild_id) && discord.guilds.has(guild_id)
+ button.s-topbar--item.s-btn.s-btn__muted.s-btn__dropdown.pr32.bar0.s-user-card(popovertarget="guilds")
+ +guild(discord.guilds.get(guild_id))
+ else if managed.size
+ button.s-topbar--item.s-btn.s-btn__muted.s-btn__dropdown.pr24.s-user-card.bar0.fc-black(popovertarget="guilds")
+ | Your servers
+ else if session.data.mxid || session.data.userID
+ .d-flex.ai-center
+ .s-badge.s-badge__bot.py6.px16.bar-md
+ | No servers available
+ #guilds(popover data-popper-placement="bottom" style="display: revert; width: revert;").s-popover.overflow-visible
+ .s-popover--arrow.s-popover--arrow__tc
+ .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)
+ +guild-menuitem(guild)
+ //- Body
+ .mx-auto.w100.wmx9.py24.px8.fs-body1#content
+ block body
+ //- Guild list popover
+ script.
+ document.querySelectorAll("[popovertarget]").forEach(e => {
+ e.addEventListener("click", () => {
+ const rect = e.getBoundingClientRect()
+ const t = `:popover-open { position: absolute; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width)}px; transform: translateX(-50%); box-sizing: content-box; margin: 0 }`
+ document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length)
+ })
+ })
+ //- Prevent default
+ script.
+ document.querySelectorAll("[cx-prevent-default]").forEach(e => {
+ e.addEventListener("click", event => {
+ event.preventDefault()
+ })
+ })
+ script(src=rel("/static/htmx.js"))
+ //- Error dialog
+ aside.s-modal#server-error(aria-hidden="true")
+ .s-modal--dialog
+ h1.s-modal--header Server error
+ pre.overflow-auto#server-error-content
+ button.s-modal--close.s-btn.s-btn__muted(aria-label="Close" type="button" onclick="hideError()")!= icons.Icons.IconClearSm
+ .s-modal--footer
+ button.s-btn.s-btn__outlined.s-btn__muted(type="button" onclick="hideError()") OK
+ script.
+ function hideError() {
+ document.getElementById("server-error").setAttribute("aria-hidden", "true")
+ }
+ document.body.addEventListener("htmx:responseError", event => {
+ document.getElementById("server-error").setAttribute("aria-hidden", "false")
+ document.getElementById("server-error-content").textContent = event.detail.xhr.responseText
+ })
diff --git a/src/web/pug/invite.pug b/src/web/pug/invite.pug
index e3468800..81a428dd 100644
--- a/src/web/pug/invite.pug
+++ b/src/web/pug/invite.pug
@@ -3,7 +3,7 @@ extends includes/template.pug
block body
if !isValid
.s-empty-state.wmx4.p48
- != icons.Spots.SpotAlertXL
+ != icons.Spots.SpotExpireXL
p This QR code has expired.
p Refresh the guild management page to generate a new one.
@@ -13,11 +13,11 @@ block body
.s-page-title.mb24
h1.s-page-title--header= guild.name
- .d-flex.g16
+ .d-flex.g16#form-container
.fl-grow1
h2.fs-headline1 Invite a Matrix user
- form.d-flex.gy16.fd-column(method="post" action="/api/invite" style="grid-template-rows: repeat(2, auto)")
+ form.d-flex.gy16.fd-column(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-indicator="#invite-button" hx-select="#ok" hx-target="#form-container")
.d-flex.gy4.fd-column
label.s-label(for="mxid") Matrix ID
input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org")
@@ -27,6 +27,7 @@ block body
select#permissions(name="permissions")
option(value="default") Default
option(value="moderator") Moderator
+ option(value="admin") Admin
input(type="hidden" name="nonce" value=nonce)
div
- button.s-btn.s-btn__filled.htmx-indicator Invite
+ button.s-btn.s-btn__filled#invite-button Invite
diff --git a/src/web/pug/log-in-with-matrix.pug b/src/web/pug/log-in-with-matrix.pug
new file mode 100644
index 00000000..3bb72e2d
--- /dev/null
+++ b/src/web/pug/log-in-with-matrix.pug
@@ -0,0 +1,16 @@
+extends includes/template.pug
+
+block body
+ .s-page-title.mb24
+ h1.s-page-title--header Log in with Matrix
+
+ .d-flex.g16#form-container
+ .fl-grow1
+ form.d-flex.gy16.fd-column(method="post" action=rel("/api/log-in-with-matrix") hx-post=rel("/api/log-in-with-matrix") hx-indicator="#log-in-button" hx-select="#ok" hx-target="#form-container")
+ if next
+ input(type="hidden" name="next" value=next)
+ .d-flex.gy4.fd-column
+ label.s-label(for="mxid") Your Matrix ID
+ input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)")
+ div
+ button.s-btn.s-btn__github#log-in-button Continue with Matrix
diff --git a/src/web/pug/ok.pug b/src/web/pug/ok.pug
index 9aed7375..bf32e8d6 100644
--- a/src/web/pug/ok.pug
+++ b/src/web/pug/ok.pug
@@ -1,6 +1,6 @@
extends includes/template.pug
block body
- .ta-center.wmx5.p48.mx-auto
- != icons.Spots.SpotApproveXL
+ .ta-center.wmx5.p48.mx-auto#ok
+ != spot ? icons.Spots[spot] : icons.Spots.SpotApproveXL
p.mt24.fs-body2= msg
diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js
index ee640747..769fc9c0 100644
--- a/src/web/routes/download-discord.js
+++ b/src/web/routes/download-discord.js
@@ -1,7 +1,7 @@
// @ts-check
const assert = require("assert/strict")
-const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError} = require("h3")
+const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError, H3Event} = require("h3")
const {z} = require("zod")
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
@@ -19,6 +19,15 @@ const schema = {
})
}
+/**
+ * @param {H3Event} event
+ * @returns {import("snowtransfer").SnowTransfer}
+ */
+function getSnow(event) {
+ /* c8 ignore next */
+ return event.context.snow || discord.snow
+}
+
/** @type {Map>} */
const cache = new Map()
@@ -29,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) {
@@ -56,11 +64,13 @@ function defineMediaProxyHandler(domain) {
if (!timeUntilExpiry(refreshed)) promise = undefined
}
if (!promise) {
- promise = discord.snow.channel.refreshAttachmentURLs([url]).then(x => x.refreshed_urls[0].refreshed)
+ const snow = getSnow(event)
+ promise = snow.channel.refreshAttachmentURLs([url]).then(x => x.refreshed_urls[0].refreshed)
cache.set(url, promise)
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()
@@ -73,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
new file mode 100644
index 00000000..e4f4ab4d
--- /dev/null
+++ b/src/web/routes/download-discord.test.js
@@ -0,0 +1,97 @@
+// @ts-check
+
+const assert = require("assert").strict
+const tryToCatch = require("try-to-catch")
+const {test} = require("supertape")
+const {router} = require("../../../test/web")
+const {_cache} = require("./download-discord")
+
+test("web download discord: access denied if not a known attachment", async t => {
+ const [error] = await tryToCatch(() =>
+ router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", {
+ params: {
+ channel_id: "1",
+ attachment_id: "2",
+ file_name: "image.png"
+ }
+ })
+ )
+ t.ok(error)
+})
+
+test("web download discord: works if a known attachment", async t => {
+ 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: {
+ 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 f9967167..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} = require("h3")
+const {defineEventHandler, getValidatedRouterParams, setResponseStatus, setResponseHeader, createError, H3Event, getValidatedQuery} = require("h3")
const {z} = require("zod")
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
@@ -11,20 +11,47 @@ require("xxhash-wasm")().then(h => hasher = h)
const {sync, as, select} = require("../../passthrough")
-/** @type {import("../../matrix/api")} */
-const api = sync.require("../../matrix/api")
+/** @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_-]+$/)
})
}
-as.router.get(`/download/matrix/:server_name/:media_id`, defineEventHandler(async event => {
- const params = await getValidatedRouterParams(event, schema.params.parse)
+/**
+ * @param {H3Event} event
+ * @returns {import("../../matrix/api")}
+ */
+function getAPI(event) {
+ /* c8 ignore next */
+ return event.context.api || sync.require("../../matrix/api")
+}
- const serverAndMediaID = `${params.server_name}/${params.media_id}`
+/**
+ * @param {H3Event} event
+ * @returns {typeof emojiSheet["getAndConvertEmoji"]}
+ */
+function getMxcDownloader(event) {
+ /* c8 ignore next */
+ return event.context.mxcDownloader || emojiSheet.getAndConvertEmoji
+}
+
+function verifyMediaHash(serverAndMediaID) {
const unsignedHash = hasher.h64(serverAndMediaID)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
@@ -35,7 +62,13 @@ 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}`)
const contentType = res.headers.get("content-type")
@@ -46,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
new file mode 100644
index 00000000..ccbcfddf
--- /dev/null
+++ b/src/web/routes/download-matrix.test.js
@@ -0,0 +1,88 @@
+// @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(() =>
+ router.test("get", "/download/matrix/:server_name/:media_id", {
+ params: {
+ server_name: "cadence.moe",
+ media_id: "1"
+ }
+ })
+ )
+ t.ok(error)
+})
+
+test("web download matrix: works if a known attachment", async t => {
+ const event = {}
+ await router.test("get", "/download/matrix/:server_name/:media_id", {
+ params: {
+ server_name: "cadence.moe",
+ media_id: "KrwlqopRyMxnEBcWDgpJZPxh",
+ },
+ event,
+ api: {
+ // @ts-ignore
+ async getMedia(mxc, init) {
+ return new Response("", {status: 200, headers: {"content-type": "image/png"}})
+ }
+ }
+ })
+ 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 7940853c..63dd3ec5 100644
--- a/src/web/routes/guild-settings.js
+++ b/src/web/routes/guild-settings.js
@@ -1,23 +1,96 @@
// @ts-check
+const assert = require("assert/strict")
const {z} = require("zod")
-const {defineEventHandler, sendRedirect, useSession, createError, readValidatedBody} = require("h3")
+const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3")
-const {as, db} = require("../../passthrough")
-const {reg} = require("../../matrix/read-registration")
+const {as, db, sync, select} = require("../../passthrough")
-const schema = {
- autocreate: z.object({
- guild_id: z.string(),
- autocreate: z.string().optional()
+/** @type {import("../auth")} */
+const auth = sync.require("../auth")
+/** @type {import("../../d2m/actions/set-presence")} */
+const setPresence = sync.require("../../d2m/actions/set-presence")
+
+/**
+ * @param {H3Event} event
+ * @returns {import("../../d2m/actions/create-space")}
+ */
+function getCreateSpace(event) {
+ /* c8 ignore next */
+ return event.context.createSpace || sync.require("../../d2m/actions/create-space")
+}
+
+/**
+ * @typedef Options
+ * @prop {(value: string?) => number} transform
+ * @prop {(event: H3Event, guildID: string) => any} [after]
+ * @prop {keyof import("../../db/orm-defs").Models} table
+ */
+
+/**
+ * @template {string} T
+ * @param {T} key
+ * @param {Partial} [inputOptions]
+ */
+function defineToggle(key, inputOptions) {
+ /** @type {Options} */
+ const options = {
+ transform: x => +!!x, // convert toggle to 0 or 1
+ table: "guild_space"
+ }
+ Object.assign(options, inputOptions)
+ return defineEventHandler(async event => {
+ const bodySchema = z.object({
+ guild_id: z.string(),
+ [key]: z.string().optional()
+ })
+ /** @type {Record & Record<"guild_id", string> & Record} */ // @ts-ignore
+ const parsedBody = await readValidatedBody(event, bodySchema.parse)
+ const managed = await auth.getManagedGuilds(event)
+ if (!managed.has(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
+
+ const value = options.transform(parsedBody[key])
+ assert(typeof value === "number")
+ db.prepare(`UPDATE ${options.table} SET ${key} = ? WHERE guild_id = ?`).run(value, parsedBody.guild_id)
+
+ return (options.after && await options.after(event, parsedBody.guild_id)) || null
})
}
-as.router.post("/api/autocreate", defineEventHandler(async event => {
- const parsedBody = await readValidatedBody(event, schema.autocreate.parse)
- const session = await useSession(event, {password: reg.as_token})
- if (!(session.data.managedGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
-
- db.prepare("UPDATE guild_active SET autocreate = ? WHERE guild_id = ?").run(+!!parsedBody.autocreate, parsedBody.guild_id)
- return sendRedirect(event, `/guild?guild_id=${parsedBody.guild_id}`, 302)
+as.router.post("/api/autocreate", defineToggle("autocreate", {
+ table: "guild_active",
+ after(event, guild_id) {
+ // If showing a partial page due to incomplete setup, need to refresh the whole page to show the alternate version
+ const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
+ if (!spaceID) {
+ if (getRequestHeader(event, "HX-Request")) {
+ setResponseHeader(event, "HX-Refresh", "true")
+ } else {
+ return sendRedirect(event, "", 302)
+ }
+ }
+ }
+}))
+
+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()
+ }
+}))
+
+as.router.post("/api/privacy-level", defineToggle("privacy_level", {
+ transform(value) {
+ assert(value)
+ const i = ["invite", "link", "directory"].indexOf(value)
+ assert.notEqual(i, -1)
+ return i
+ },
+ async after(event, guildID) {
+ const createSpace = getCreateSpace(event)
+ await createSpace.syncSpaceFully(guildID) // this is inefficient but OK to call infrequently on user request
+ }
}))
diff --git a/src/web/routes/guild-settings.test.js b/src/web/routes/guild-settings.test.js
new file mode 100644
index 00000000..fccc2660
--- /dev/null
+++ b/src/web/routes/guild-settings.test.js
@@ -0,0 +1,96 @@
+// @ts-check
+
+const tryToCatch = require("try-to-catch")
+const {router, test} = require("../../../test/web")
+const {select} = require("../../passthrough")
+const {MatrixServerError} = require("../../matrix/mreq")
+
+test("web autocreate: checks permissions", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/autocreate", {
+ body: {
+ guild_id: "66192955777486848"
+ }
+ }))
+ t.equal(error.data, "Can't change settings for a guild you don't have Manage Server permissions in")
+})
+
+
+test("web autocreate: turns off autocreate and does htmx page refresh when guild not linked", async t => {
+ const event = {}
+ await router.test("post", "/api/autocreate", {
+ sessionData: {
+ managedGuilds: ["66192955777486848"]
+ },
+ body: {
+ guild_id: "66192955777486848",
+ // autocreate is false
+ },
+ headers: {
+ "hx-request": "true"
+ },
+ event
+ })
+ t.equal(event.node.res.getHeader("hx-refresh"), "true")
+ t.equal(select("guild_active", "autocreate", {guild_id: "66192955777486848"}).pluck().get(), 0)
+})
+
+test("web autocreate: turns on autocreate and issues 302 when not using htmx", async t => {
+ const event = {}
+ await router.test("post", "/api/autocreate", {
+ sessionData: {
+ managedGuilds: ["66192955777486848"]
+ },
+ body: {
+ guild_id: "66192955777486848",
+ autocreate: "yes"
+ },
+ event
+ })
+ t.equal(event.node.res.getHeader("location"), "")
+ t.equal(select("guild_active", "autocreate", {guild_id: "66192955777486848"}).pluck().get(), 1)
+})
+
+test("web privacy level: checks permissions", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/privacy-level", {
+ body: {
+ guild_id: "112760669178241024",
+ privacy_level: "directory"
+ }
+ }))
+ t.equal(error.data, "Can't change settings for a guild you don't have Manage Server permissions in")
+})
+
+test("web privacy level: updates privacy level", async t => {
+ let called = 0
+ await router.test("post", "/api/privacy-level", {
+ sessionData: {
+ managedGuilds: ["112760669178241024"]
+ },
+ body: {
+ guild_id: "112760669178241024",
+ privacy_level: "directory"
+ },
+ createSpace: {
+ async syncSpaceFully(guildID) {
+ called++
+ t.equal(guildID, "112760669178241024")
+ return ""
+ }
+ }
+ })
+ t.equal(called, 1)
+ t.equal(select("guild_space", "privacy_level", {guild_id: "112760669178241024"}).pluck().get(), 2) // directory = 2
+})
+
+test("web presence: updates presence", async t => {
+ await router.test("post", "/api/presence", {
+ sessionData: {
+ managedGuilds: ["112760669178241024"]
+ },
+ body: {
+ guild_id: "112760669178241024"
+ // presence is on by default - turn it off
+ }
+ })
+ t.equal(select("guild_space", "presence", {guild_id: "112760669178241024"}).pluck().get(), 0)
+})
diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js
new file mode 100644
index 00000000..a5508c4a
--- /dev/null
+++ b/src/web/routes/guild.js
@@ -0,0 +1,268 @@
+// @ts-check
+
+const DiscordTypes = require("discord-api-types/v10")
+const assert = require("assert/strict")
+const {z} = require("zod")
+const {H3Event, defineEventHandler, sendRedirect, createError, getValidatedQuery, readValidatedBody, setResponseHeader} = require("h3")
+const {randomUUID} = require("crypto")
+const {LRUCache} = require("lru-cache")
+const Ty = require("../../types")
+const uqr = require("uqr")
+
+const {id: botID} = require("../../../addbot")
+const {discord, as, sync, select, from, db} = require("../../passthrough")
+/** @type {import("../pug-sync")} */
+const pugSync = sync.require("../pug-sync")
+/** @type {import("../../d2m/actions/create-space")} */
+const createSpace = sync.require("../../d2m/actions/create-space")
+/** @type {import("../auth")} */
+const auth = require("../auth")
+/** @type {import("../../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 = {
+ guild: z.object({
+ guild_id: z.string().optional()
+ }),
+ qr: z.object({
+ guild_id: z.string().optional()
+ }),
+ invite: z.object({
+ mxid: z.string().regex(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/),
+ permissions: z.enum(["default", "moderator", "admin"]),
+ guild_id: z.string().optional(),
+ nonce: z.string().optional()
+ }),
+ inviteNonce: z.object({
+ nonce: z.string()
+ })
+}
+
+/**
+ * @param {H3Event} event
+ * @returns {import("../../matrix/api")}
+ */
+function getAPI(event) {
+ /* c8 ignore next */
+ return event.context.api || sync.require("../../matrix/api")
+}
+
+/** @type {LRUCache} nonce to guild id */
+const validNonce = new LRUCache({max: 200})
+
+/**
+ * @param {{type: number, parent_id?: string | null, position?: number}} channel
+ * @param {Map} channels
+ */
+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
+ }
+ 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
+}
+
+/**
+ * @param {DiscordTypes.APIGuild} guild
+ * @param {Ty.R.Hierarchy[]} rooms
+ * @param {string[]} roles
+ */
+function getChannelRoomsLinks(guild, rooms, roles) {
+ 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 => ({
+ // @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, 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 = 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, discord.channels) - getPosition(b, discord.channels))
+
+ let linkedRoomIDs = linkedChannels.map(c => c.room_id)
+ let unlinkedRooms = [...rooms]
+ 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 = dUtils.filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/))
+
+ return {
+ linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms,
+ removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms
+ }
+}
+
+/**
+ * @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)
+ const managed = await auth.getManagedGuilds(event)
+ const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get()
+ // @ts-ignore
+ const guild = discord.guilds.get(guild_id)
+
+ // Permission problems
+ if (!guild_id || !guild || !managed.has(guild_id) || !row) {
+ return pugSync.render(event, "guild_access_denied.pug", {guild_id, row})
+ }
+
+ // 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 = session.data.mxid ? getInviteTargetSpaces(session.data.mxid) : []
+ return pugSync.render(event, "guild_not_linked.pug", {guild, guild_id, spaces})
+ }
+
+ const roles = guild.members?.find(m => m.user.id === botID)?.roles || []
+
+ // Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space
+ if (!row.space_id) {
+ const links = getChannelRoomsLinks(guild, [], roles)
+ return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row})
+ }
+
+ // Linked guild
+ const api = getAPI(event)
+ const rooms = await api.getFullHierarchy(row.space_id)
+ const links = getChannelRoomsLinks(guild, rooms, roles)
+ return pugSync.render(event, "guild.pug", {guild, guild_id, ...links, ...row})
+}))
+
+as.router.get("/qr", defineEventHandler(async event => {
+ const {guild_id} = await getValidatedQuery(event, schema.qr.parse)
+ const managed = await auth.getManagedGuilds(event)
+ const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get()
+ // @ts-ignore
+ const guild = discord.guilds.get(guild_id)
+
+ // Permission problems
+ if (!guild_id || !guild || !managed.has(guild_id) || !row) {
+ return pugSync.render(event, "guild_access_denied.pug", {guild_id, row})
+ }
+
+ const nonce = randomUUID()
+ validNonce.set(nonce, guild_id)
+
+ const data = `${reg.ooye.bridge_origin}/invite?nonce=${nonce}`
+ // necessary to scale the svg pixel-perfectly on the page
+ // https://github.com/unjs/uqr/blob/244952a8ba2d417f938071b61e11fb1ff95d6e75/src/svg.ts#L24
+ const generatedSvg = uqr.renderSVG(data, {pixelSize: 3})
+ const svg = generatedSvg.replace(/viewBox="0 0 ([0-9]+) ([0-9]+)"/, `data-nonce="${nonce}" width="$1" height="$2" $&`)
+ assert.notEqual(svg, generatedSvg)
+
+ return svg
+}))
+
+as.router.get("/invite", defineEventHandler(async event => {
+ const {nonce} = await getValidatedQuery(event, schema.inviteNonce.parse)
+ const isValid = validNonce.has(nonce)
+ const guild_id = validNonce.get(nonce)
+ const guild = discord.guilds.get(guild_id || "")
+ return pugSync.render(event, "invite.pug", {isValid, nonce, guild_id, guild})
+}))
+
+as.router.post("/api/invite", defineEventHandler(async event => {
+ const parsedBody = await readValidatedBody(event, schema.invite.parse)
+ const managed = await auth.getManagedGuilds(event)
+ const api = getAPI(event)
+
+ // Check guild ID or nonce
+ if (parsedBody.guild_id) {
+ var guild_id = parsedBody.guild_id
+ if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't invite users to a guild you don't have Manage Server permissions in"})
+ } else if (parsedBody.nonce) {
+ if (!validNonce.has(parsedBody.nonce)) throw createError({status: 403, message: "Nonce expired", data: "Nonce means number-used-once, and, well, you tried to use it twice..."})
+ let ok = validNonce.get(parsedBody.nonce)
+ assert(ok)
+ var guild_id = ok
+ validNonce.delete(parsedBody.nonce)
+ } else {
+ throw createError({status: 400, message: "Missing guild ID", data: "Passing a guild ID or a nonce is required."})
+ }
+
+ // Check guild is bridged
+ const guild = discord.guilds.get(guild_id)
+ assert(guild)
+ const spaceID = await createSpace.ensureSpace(guild)
+
+ // Check for existing invite to the space
+ let spaceMember
+ try {
+ spaceMember = await api.getStateEvent(spaceID, "m.room.member", parsedBody.mxid)
+ } catch (e) {}
+
+ if (!spaceMember || !["invite", "join"].includes(spaceMember.membership)) {
+ // Invite
+ await api.inviteToRoom(spaceID, parsedBody.mxid)
+ }
+
+ // Permissions
+ const powerLevel =
+ ( parsedBody.permissions === "admin" ? 100
+ : parsedBody.permissions === "moderator" ? 50
+ : 0)
+ if (powerLevel) await mxUtils.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel, api)
+
+ if (parsedBody.guild_id) {
+ setResponseHeader(event, "HX-Refresh", true)
+ return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302)
+ } else {
+ 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
new file mode 100644
index 00000000..aa17548e
--- /dev/null
+++ b/src/web/routes/guild.test.js
@@ -0,0 +1,396 @@
+// @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
+
+test("web guild: access denied when not logged in", async t => {
+ const html = await router.test("get", "/guild?guild_id=112760669178241024", {
+ sessionData: {
+ },
+ })
+ t.has(html, "You need to log in to manage your servers.")
+})
+
+test("web guild: asks to select guild if not selected", async t => {
+ const html = await router.test("get", "/guild", {
+ sessionData: {
+ userID: "1",
+ managedGuilds: []
+ },
+ })
+ t.has(html, "Select a server from the top right corner to continue.")
+})
+
+test("web guild: access denied when guild id messed up", async t => {
+ const html = await router.test("get", "/guild?guild_id=1", {
+ sessionData: {
+ userID: "1",
+ managedGuilds: []
+ },
+ })
+ t.has(html, "the selected server doesn't exist")
+})
+
+test("web qr: access denied when guild id messed up", async t => {
+ const html = await router.test("get", "/qr?guild_id=1", {
+ sessionData: {
+ userID: "1",
+ managedGuilds: []
+ },
+ })
+ t.has(html, "the selected server doesn't exist")
+})
+
+test("web invite: access denied with invalid nonce", async t => {
+ const html = await router.test("get", "/invite?nonce=1")
+ t.match(html, /This QR code has expired./)
+})
+
+
+
+test("web guild: can view unbridged guild", async t => {
+ const html = await router.test("get", "/guild?guild_id=66192955777486848", {
+ sessionData: {
+ managedGuilds: ["66192955777486848"]
+ }
+ })
+ t.has(html, `Function & Arg
`)
+})
+
+test("web guild: unbridged self-service guild prompts log in to matrix", async t => {
+ const html = await router.test("get", "/guild?guild_id=665289423482519565", {
+ sessionData: {
+ managedGuilds: ["665289423482519565"]
+ }
+ })
+ t.has(html, `You picked self-service mode`)
+ t.has(html, `You need to log in with Matrix first`)
+})
+
+test("web guild: unbridged self-service guild asks to be invited", async t => {
+ const html = await router.test("get", "/guild?guild_id=665289423482519565", {
+ sessionData: {
+ mxid: "@user:example.org",
+ managedGuilds: ["665289423482519565"]
+ }
+ })
+ t.has(html, `On Matrix, invite <`)
+})
+
+test("web guild: unbridged self-service guild shows available spaces", async t => {
+ const html = await router.test("get", "/guild?guild_id=665289423482519565", {
+ sessionData: {
+ mxid: "@cadence:cadence.moe",
+ managedGuilds: ["665289423482519565"]
+ }
+ })
+ t.has(html, `Data Horde`)
+ t.has(html, `here is the space topic `)
+ t.has(html, `
`)
+ t.notMatch(html, /some room<\/strong>/)
+ t.notMatch(html, /somebody else's space<\/strong>/)
+})
+
+
+test("web guild: can view bridged guild when logged in with discord", async t => {
+ const html = await router.test("get", "/guild?guild_id=112760669178241024", {
+ sessionData: {
+ managedGuilds: ["112760669178241024"]
+ },
+ api: {
+ async getFullHierarchy(roomID) {
+ return []
+ }
+ }
+ })
+ t.has(html, `Psychonauts 3
`)
+})
+
+test("web guild: can view bridged guild when logged in with matrix", async t => {
+ const html = await router.test("get", "/guild?guild_id=112760669178241024", {
+ sessionData: {
+ mxid: "@cadence:cadence.moe"
+ },
+ api: {
+ async getFullHierarchy(roomID) {
+ return []
+ }
+ }
+ })
+ t.has(html, `Psychonauts 3
`)
+})
+
+test("web qr: generates nonce", async t => {
+ const html = await router.test("get", "/qr?guild_id=112760669178241024", {
+ sessionData: {
+ managedGuilds: ["112760669178241024"]
+ }
+ })
+ nonce = html.match(/data-nonce="([a-f0-9-]+)"/)?.[1]
+ t.ok(nonce)
+})
+
+test("web invite: page loads with valid nonce", async t => {
+ const html = await router.test("get", `/invite?nonce=${nonce}`)
+ t.has(html, "Invite a Matrix user")
+})
+
+
+
+
+test("api invite: access denied with nothing", async t => {
+ const [error] = await tryToCatch(() =>
+ router.test("post", `/api/invite`, {
+ body: {
+ mxid: "@cadence:cadence.moe",
+ permissions: "moderator"
+ }
+ })
+ )
+ t.equal(error.message, "Missing guild ID")
+})
+
+test("api invite: access denied when not in guild", async t => {
+ const [error] = await tryToCatch(() =>
+ router.test("post", `/api/invite`, {
+ body: {
+ mxid: "@cadence:cadence.moe",
+ permissions: "moderator",
+ guild_id: "112760669178241024"
+ }
+ })
+ )
+ t.equal(error.message, "Forbidden")
+})
+
+test("api invite: can invite with valid nonce", async t => {
+ let called = 0
+ const [error] = await tryToCatch(() =>
+ router.test("post", `/api/invite`, {
+ body: {
+ mxid: "@cadence:cadence.moe",
+ permissions: "moderator",
+ nonce
+ },
+ api: {
+ async getStateEvent(roomID, type, key) {
+ called++
+ 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")
+ },
+ 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)
+ /*
+ 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 => {
+ const [error] = await tryToCatch(() =>
+ router.test("post", `/api/invite`, {
+ body: {
+ mxid: "@cadence:cadence.moe",
+ permissions: "moderator",
+ nonce
+ }
+ })
+ )
+ t.equal(error.message, "Nonce expired")
+})
+
+test("api invite: can invite to a moderated guild", async t => {
+ let called = 0
+ const [error] = await tryToCatch(() =>
+ router.test("post", `/api/invite`, {
+ body: {
+ mxid: "@cadence:cadence.moe",
+ permissions: "admin",
+ guild_id: "112760669178241024"
+ },
+ sessionData: {
+ managedGuilds: ["112760669178241024"]
+ },
+ api: {
+ async getStateEvent(roomID, type, key) {
+ called++
+ 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")
+ },
+ 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, 9)
+})
+
+test("api invite: does not reinvite joined users", async t => {
+ let called = 0
+ const [error] = await tryToCatch(() =>
+ router.test("post", `/api/invite`, {
+ body: {
+ mxid: "@cadence:cadence.moe",
+ permissions: "default",
+ guild_id: "112760669178241024"
+ },
+ sessionData: {
+ managedGuilds: ["112760669178241024"]
+ },
+ api: {
+ async getStateEvent(roomID, type, key) {
+ called++
+ return {membership: "join"}
+ }
+ }
+ })
+ )
+ 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
new file mode 100644
index 00000000..e83bf89f
--- /dev/null
+++ b/src/web/routes/info.js
@@ -0,0 +1,76 @@
+// @ts-check
+
+const {z} = require("zod")
+const {defineEventHandler, getValidatedQuery, H3Event} = require("h3")
+const {as, from, sync, select} = require("../../passthrough")
+
+/** @type {import("../../matrix/utils")} */
+const mUtils = sync.require("../../matrix/utils")
+
+/**
+ * @param {H3Event} event
+ * @returns {import("../../matrix/api")}
+ */
+function getAPI(event) {
+ /* c8 ignore next */
+ return event.context.api || sync.require("../../matrix/api")
+}
+
+const schema = {
+ message: z.object({
+ message_id: z.string().regex(/^[0-9]+$/)
+ })
+}
+
+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_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: {
+ 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
+ }))
+ ))
+
+ /* c8 ignore next */
+ const primary = events.find(e => e.metadata.part === 0) || events[0]
+ const mxid = primary.metadata.sender
+ const source = primary.metadata.source === 0 ? "matrix" : "discord"
+
+ let matrix_author = undefined
+ if (source === "matrix") {
+ matrix_author = select("member_cache", ["displayname", "avatar_url", "mxid"], {room_id: primary.metadata.room_id, mxid}).get()
+ if (!matrix_author) {
+ try {
+ matrix_author = await api.getProfile(mxid)
+ } catch (e) {
+ matrix_author = {}
+ }
+ }
+ if (!matrix_author.displayname) matrix_author.displayname = mxid
+ matrix_author.avatar_url = mUtils.getPublicUrlForMxc(matrix_author.avatar_url) || null
+ matrix_author["mxid"] = mxid
+ }
+
+ return {source, matrix_author, events}
+}))
diff --git a/src/web/routes/info.test.js b/src/web/routes/info.test.js
new file mode 100644
index 00000000..39b2c00d
--- /dev/null
+++ b/src/web/routes/info.test.js
@@ -0,0 +1,227 @@
+// @ts-check
+
+const assert = require("assert/strict")
+const {router, test} = require("../../../test/web")
+
+test("web info: returns 404 when message doesn't exist", async t => {
+ const res = await router.test("get", "/api/message?message_id=1")
+ assert(res instanceof Response)
+ t.equal(res.status, 404)
+})
+
+test("web info: returns data for a matrix message and profile", async t => {
+ let called = 0
+ const raw = {
+ type: "m.room.message",
+ room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ content: {
+ msgtype: "m.text",
+ body: "testing :heart_pink: :heart_pink: ",
+ format: "org.matrix.custom.html",
+ formatted_body: "testing
"
+ },
+ origin_server_ts: 1739312945302,
+ unsigned: {
+ membership: "join",
+ age: 10063702303
+ },
+ event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk",
+ user_id: "@cadence:cadence.moe",
+ age: 10063702303
+ }
+ const res = await router.test("get", "/api/message?message_id=1339000288144658482", {
+ api: {
+ // @ts-ignore - returning static data when method could be called with a different typescript generic
+ async getEvent(roomID, eventID) {
+ called++
+ t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe")
+ t.equal(eventID, "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk")
+ return raw
+ },
+ async getProfile(mxid) {
+ called++
+ t.equal(mxid, "@cadence:cadence.moe")
+ return {
+ displayname: "okay 🤍 yay 🤍"
+ }
+ }
+ }
+ })
+ t.deepEqual(res, {
+ source: "matrix",
+ matrix_author: {
+ displayname: "okay 🤍 yay 🤍",
+ avatar_url: null,
+ mxid: "@cadence:cadence.moe"
+ },
+ 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",
+ channel_id: "176333891320283136",
+ current_room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ source: 0
+ },
+ raw
+ }]
+ })
+ t.equal(called, 2)
+})
+
+test("web info: returns data for a matrix message without profile", async t => {
+ let called = 0
+ const raw = {
+ type: "m.room.message",
+ room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ content: {
+ msgtype: "m.text",
+ body: "testing :heart_pink: :heart_pink: ",
+ format: "org.matrix.custom.html",
+ formatted_body: "testing
"
+ },
+ origin_server_ts: 1739312945302,
+ unsigned: {
+ membership: "join",
+ age: 10063702303
+ },
+ event_id: "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk",
+ user_id: "@cadence:cadence.moe",
+ age: 10063702303
+ }
+ const res = await router.test("get", "/api/message?message_id=1339000288144658482", {
+ api: {
+ // @ts-ignore - returning static data when method could be called with a different typescript generic
+ async getEvent(roomID, eventID) {
+ called++
+ t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe")
+ t.equal(eventID, "$51gH61p_eJc2RylOdE2lAr4-ogP7dS0WJI62lCFzBvk")
+ return raw
+ }
+ }
+ })
+ t.deepEqual(res, {
+ source: "matrix",
+ matrix_author: {
+ displayname: "@cadence:cadence.moe",
+ avatar_url: null,
+ mxid: "@cadence:cadence.moe"
+ },
+ 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",
+ channel_id: "176333891320283136",
+ current_room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe",
+ sender: "@cadence:cadence.moe",
+ source: 0
+ },
+ raw
+ }]
+ })
+ t.equal(called, 1)
+})
+
+test("web info: returns data for a discord message", async t => {
+ let called = 0
+ const raw1 = {
+ type: "m.room.message",
+ sender: "@_ooye_accavish:cadence.moe",
+ content: {
+ "m.mentions": {},
+ msgtype: "m.text",
+ body: "brony music mentioned on wikipedia's did you know and also unrelated cat pic"
+ },
+ origin_server_ts: 1749377203735,
+ unsigned: {
+ membership: "join",
+ age: 119
+ },
+ event_id: "$AfrB8hzXkDMvuoWjSZkDdFYomjInWH7jMBPkwQMN8AI",
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
+ }
+ const raw2 = {
+ type: "m.room.message",
+ sender: "@_ooye_accavish:cadence.moe",
+ content: {
+ "m.mentions": {},
+ msgtype: "m.image",
+ url: "mxc://cadence.moe/ABOMymxHcpVeecHvmSIYmYXx",
+ external_url: "https://bridge.cadence.moe/download/discordcdn/112760669178241024/1381212840710504448/image.png",
+ body: "image.png",
+ filename: "image.png",
+ info: {
+ mimetype: "image/png",
+ w: 966,
+ h: 368,
+ size: 166060
+ }
+ },
+ origin_server_ts: 1749377203789,
+ unsigned: {
+ membership: "join",
+ age: 65
+ },
+ event_id: "$43baKEhJfD-RlsFQi0LB16Zxd8yMqp0HSVL00TDQOqM",
+ room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
+ }
+ const res = await router.test("get", "/api/message?message_id=1381212840957972480", {
+ api: {
+ // @ts-ignore - returning static data when method could be called with a different typescript generic
+ async getEvent(roomID, eventID) {
+ called++
+ t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
+ if (eventID === raw1.event_id) {
+ return raw1
+ } else {
+ assert(eventID === raw2.event_id)
+ return raw2
+ }
+ }
+ }
+ })
+ t.deepEqual(res, {
+ source: "discord",
+ 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",
+ channel_id: "112760669178241024",
+ current_room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
+ sender: "@_ooye_accavish:cadence.moe",
+ source: 1
+ },
+ raw: raw1
+ }, {
+ metadata: {
+ event_id: "$43baKEhJfD-RlsFQi0LB16Zxd8yMqp0HSVL00TDQOqM",
+ event_subtype: "m.image",
+ event_type: "m.room.message",
+ 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
+ },
+ raw: raw2
+ }]
+ })
+ t.equal(called, 2)
+})
diff --git a/src/web/routes/invite.js b/src/web/routes/invite.js
deleted file mode 100644
index eec7a3c6..00000000
--- a/src/web/routes/invite.js
+++ /dev/null
@@ -1,99 +0,0 @@
-// @ts-check
-
-const assert = require("assert/strict")
-const {z} = require("zod")
-const {defineEventHandler, sendRedirect, useSession, createError, getValidatedQuery, readValidatedBody} = require("h3")
-const {randomUUID} = require("crypto")
-const {LRUCache} = require("lru-cache")
-
-const {discord, as, sync, select} = require("../../passthrough")
-/** @type {import("../pug-sync")} */
-const pugSync = sync.require("../pug-sync")
-const {reg} = require("../../matrix/read-registration")
-
-/** @type {import("../../matrix/api")} */
-const api = sync.require("../../matrix/api")
-
-const schema = {
- guild: z.object({
- guild_id: z.string().optional()
- }),
- invite: z.object({
- mxid: z.string().regex(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/),
- permissions: z.enum(["default", "moderator"]),
- guild_id: z.string().optional(),
- nonce: z.string().optional()
- }),
- inviteNonce: z.object({
- nonce: z.string()
- })
-}
-
-/** @type {LRUCache} nonce to guild id */
-const validNonce = new LRUCache({max: 200})
-
-as.router.get("/guild", defineEventHandler(async event => {
- const {guild_id} = await getValidatedQuery(event, schema.guild.parse)
- const nonce = randomUUID()
- if (guild_id) {
- // Security note: the nonce alone is valid for updating the guild
- // We have not verified the user has sufficient permissions in the guild at generation time
- // These permissions are checked later during page rendering and the generated nonce is only revealed if the permissions are sufficient
- validNonce.set(nonce, guild_id)
- }
- return pugSync.render(event, "guild.pug", {nonce})
-}))
-
-as.router.get("/invite", defineEventHandler(async event => {
- const {nonce} = await getValidatedQuery(event, schema.inviteNonce.parse)
- const isValid = validNonce.has(nonce)
- const guild_id = validNonce.get(nonce)
- const guild = discord.guilds.get(guild_id || "")
- return pugSync.render(event, "invite.pug", {isValid, nonce, guild_id, guild})
-}))
-
-as.router.post("/api/invite", defineEventHandler(async event => {
- const parsedBody = await readValidatedBody(event, schema.invite.parse)
- const session = await useSession(event, {password: reg.as_token})
-
- // Check guild ID or nonce
- if (parsedBody.guild_id) {
- var guild_id = parsedBody.guild_id
- if (!(session.data.managedGuilds || []).includes(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't invite users to a guild you don't have Manage Server permissions in"})
- } else if (parsedBody.nonce) {
- if (!validNonce.has(parsedBody.nonce)) throw createError({status: 403, message: "Nonce expired", data: "Nonce means number-used-once, and, well, you tried to use it twice..."})
- let ok = validNonce.get(parsedBody.nonce)
- assert(ok)
- var guild_id = ok
- validNonce.delete(parsedBody.nonce)
- } else {
- throw createError({status: 400, message: "Missing guild ID", data: "Passing a guild ID or a nonce is required."})
- }
-
- // Check guild is bridged
- const spaceID = select("guild_space", "space_id", {guild_id: guild_id}).pluck().get()
- if (!spaceID) throw createError({status: 428, message: "Server not bridged", data: "You can only invite Matrix users to servers that are bridged to Matrix."})
-
- // Check for existing invite to the space
- let spaceMember
- try {
- spaceMember = await api.getStateEvent(spaceID, "m.room.member", parsedBody.mxid)
- } catch (e) {}
- if (spaceMember && (spaceMember.membership === "invite" || spaceMember.membership === "join")) {
- return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302)
- }
-
- // Invite
- await api.inviteToRoom(spaceID, parsedBody.mxid)
-
- // Permissions
- if (parsedBody.permissions === "moderator") {
- await api.setUserPowerCascade(spaceID, parsedBody.mxid, 50)
- }
-
- if (parsedBody.guild_id) {
- return sendRedirect(event, `/guild?guild_id=${guild_id}`, 302)
- } else {
- return sendRedirect(event, "/ok?msg=User has been invited.", 302)
- }
-}))
diff --git a/src/web/routes/link.js b/src/web/routes/link.js
new file mode 100644
index 00000000..43995fcd
--- /dev/null
+++ b/src/web/routes/link.js
@@ -0,0 +1,298 @@
+// @ts-check
+
+const assert = require("assert").strict
+const {z} = require("zod")
+const {defineEventHandler, createError, readValidatedBody, setResponseHeader, H3Event} = require("h3")
+const Ty = require("../../types")
+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/utils")}*/
+const utils = sync.require("../../matrix/utils")
+/** @type {import("./guild")}*/
+const guildRoute = sync.require("./guild")
+
+/**
+ * @param {H3Event} event
+ * @returns {import("../../matrix/api")}
+ */
+function getAPI(event) {
+ /* c8 ignore next */
+ return event.context.api || sync.require("../../matrix/api")
+}
+
+/**
+ * @param {H3Event} event
+ * @returns {import("../../d2m/actions/create-room")}
+ */
+function getCreateRoom(event) {
+ /* c8 ignore next */
+ return event.context.createRoom || sync.require("../../d2m/actions/create-room")
+}
+
+/**
+ * @param {H3Event} event
+ * @returns {import("../../d2m/actions/create-space")}
+ */
+function getCreateSpace(event) {
+ /* c8 ignore next */
+ return event.context.createSpace || sync.require("../../d2m/actions/create-space")
+}
+
+/**
+ * @param {H3Event} event
+ * @returns {import("snowtransfer").SnowTransfer}
+ */
+function getSnow(event) {
+ /* c8 ignore next */
+ return event.context.snow || discord.snow
+}
+
+const schema = {
+ linkSpace: z.object({
+ guild_id: z.string(),
+ space_id: z.string()
+ }),
+ link: z.object({
+ guild_id: z.string(),
+ matrix: z.string(),
+ discord: z.string()
+ }),
+ unlink: z.object({
+ guild_id: z.string(),
+ channel_id: z.string()
+ }),
+ unlinkSpace: z.object({
+ guild_id: z.string(),
+ }),
+}
+
+/**
+ * @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)
+ const managed = await auth.getManagedGuilds(event)
+ const api = getAPI(event)
+
+ // Check guild ID
+ const guildID = parsedBody.guild_id
+ if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
+
+ // 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
+
+ // 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`})
+
+ // 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 {
+ await api.joinRoom(parsedBody.space_id, null, via)
+ } catch (e) {
+ throw createError({status: 400, message: "Unable To Join", data: `Unable to join the requested Matrix space. Please invite the bridge to the space and try again. (Server said: ${e.errcode} - ${e.message})`})
+ }
+
+ // Check bridge has PL 100
+ 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
+ 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(() => {
+ db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guildID, spaceID)
+ db.prepare("DELETE FROM invite WHERE room_id = ?").run(spaceID)
+ })()
+
+ setResponseHeader(event, "HX-Refresh", "true")
+ return null // 204
+}))
+
+as.router.post("/api/link", defineEventHandler(async event => {
+ const parsedBody = await readValidatedBody(event, schema.link.parse)
+ const managed = await auth.getManagedGuilds(event)
+ const api = getAPI(event)
+ const createRoom = getCreateRoom(event)
+ const createSpace = getCreateSpace(event)
+
+ // Check guild ID or nonce
+ const guildID = parsedBody.guild_id
+ if (!managed.has(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
+
+ // Check guild is bridged
+ const guild = discord.guilds.get(guildID)
+ if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"})
+ const spaceID = await createSpace.ensureSpace(guild)
+
+ // Check channel exists
+ const channel = discord.channels.get(parsedBody.discord)
+ if (!channel) throw createError({status: 400, message: "Bad Request", data: "Discord channel does not exist"})
+
+ // Check channel is part of the guild
+ if (!("guild_id" in channel) || channel.guild_id !== guildID) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel.id} is not part of guild ${guildID}`})
+
+ // Check channel and room are not already bridged
+ const row = from("channel_room").select("channel_id", "room_id").and("WHERE channel_id = ? OR room_id = ?").get(channel.id, parsedBody.matrix)
+ if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} or room ID ${parsedBody.matrix} are already bridged and cannot be reused`})
+
+ // Check room is part of the guild's space
+ let foundRoom = false
+ /** @type {string[]?} */
+ let foundVia = null
+ for await (const room of api.generateFullHierarchy(spaceID)) {
+ // When finding a space during iteration, look at space's children state, because we need a `via` to join the room (when we find it later)
+ for (const state of room.children_state) {
+ if (state.type === "m.space.child" && state.state_key === parsedBody.matrix) {
+ foundVia = state.content.via
+ }
+ }
+
+ // When finding a room during iteration, see if it was the requested room (to confirm that the room is in the space)
+ if (room.room_id === parsedBody.matrix && !room.room_type) {
+ foundRoom = true
+ }
+
+ if (foundRoom && foundVia) break
+ }
+ if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"})
+
+ // Check room exists and bridge is joined
+ try {
+ await api.joinRoom(parsedBody.matrix, null, foundVia)
+ } catch (e) {
+ if (!foundVia) {
+ throw createError({status: 400, message: "Unable To Join", data: `Unable to join the requested Matrix room. Please invite the bridge to the room and try again. (Server said: ${e.errcode} - ${e.message})`})
+ }
+ throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`})
+ }
+
+ // Check bridge has PL 100
+ 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.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)
+
+ // Send a notification in the room
+ if (channel.type === DiscordTypes.ChannelType.GuildText) {
+ await api.sendEvent(parsedBody.matrix, "m.room.message", {
+ msgtype: "m.notice",
+ body: "👋 This room is now bridged with Discord. Say hi!"
+ })
+ }
+
+ setResponseHeader(event, "HX-Refresh", "true")
+ return null // 204
+}))
+
+as.router.post("/api/unlink", defineEventHandler(async event => {
+ const {channel_id, guild_id} = await readValidatedBody(event, schema.unlink.parse)
+ const managed = await auth.getManagedGuilds(event)
+
+ // Check guild ID or nonce
+ if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
+
+ // Check guild exists
+ 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 validateAndUnbridgeChannel(event, channel_id, guild_id)
+
+ setResponseHeader(event, "HX-Refresh", "true")
+ return null // 204
+}))
+
+as.router.post("/api/unlink-space", defineEventHandler(async event => {
+ const {guild_id} = await readValidatedBody(event, schema.unlinkSpace.parse)
+ const managed = await auth.getManagedGuilds(event)
+ const api = getAPI(event)
+ const snow = getSnow(event)
+
+ // Check guild ID or nonce
+ if (!managed.has(guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
+
+ // Check guild exists
+ 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) {
+ // 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)
+ }
+
+ // Mark as not considered for bridging
+ db.prepare("DELETE FROM guild_active WHERE guild_id = ?").run(guild_id)
+ await snow.user.leaveGuild(guild_id)
+
+ setResponseHeader(event, "HX-Redirect", "/")
+ return null
+}))
diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js
new file mode 100644
index 00000000..e8473f85
--- /dev/null
+++ b/src/web/routes/link.test.js
@@ -0,0 +1,845 @@
+// @ts-check
+
+const tryToCatch = require("try-to-catch")
+const {router, test} = require("../../../test/web")
+const {MatrixServerError} = require("../../matrix/mreq")
+const {select, db} = require("../../passthrough")
+const assert = require("assert").strict
+
+test("web link space: access denied when not logged in to Discord", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/link-space", {
+ sessionData: {
+ },
+ body: {
+ space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
+ guild_id: "665289423482519565"
+ }
+ }))
+ t.equal(error.data, "Can't edit a guild you don't have Manage Server permissions in")
+})
+
+test("web link space: access denied when not logged in to Matrix", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/link-space", {
+ sessionData: {
+ managedGuilds: ["665289423482519565"]
+ },
+ body: {
+ space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
+ guild_id: "665289423482519565"
+ }
+ }))
+ t.equal(error.data, "Can't link with your Matrix space if you aren't logged in to Matrix")
+})
+
+test("web link space: access denied when bot was invited by different user", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/link-space", {
+ sessionData: {
+ managedGuilds: ["665289423482519565"],
+ mxid: "@user:example.org"
+ },
+ body: {
+ space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
+ guild_id: "665289423482519565"
+ }
+ }))
+ t.equal(error.data, "You personally must invite OOYE to that space on Matrix")
+})
+
+test("web link space: access denied when guild is already in use", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/link-space", {
+ sessionData: {
+ managedGuilds: ["112760669178241024"],
+ mxid: "@cadence:cadence.moe"
+ },
+ body: {
+ space_id: "!jjmvBegULiLucuWEHU:cadence.moe",
+ guild_id: "112760669178241024"
+ }
+ }))
+ t.equal(error.data, "Guild ID 112760669178241024 or space ID !jjmvBegULiLucuWEHU:cadence.moe are already bridged and cannot be reused")
+})
+
+test("web link space: check that OOYE is joined", 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++
+ throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"})
+ }
+ }
+ }))
+ t.equal(error.data, "Unable to join the requested Matrix space. Please invite the bridge to the space and try again. (Server said: M_FORBIDDEN - not allowed to join I guess)")
+ t.equal(called, 1)
+})
+
+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", {
+ 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 {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, 3)
+})
+
+test("web link space: check that inviting user has PL 50", 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 {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, 3)
+})
+
+test("web link space: successfully adds entry to database and loads page", async t => {
+ let called = 0
+ await 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 {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, 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)
+
+ // check that the guild info page now loads
+ const html = await router.test("get", "/guild?guild_id=665289423482519565", {
+ sessionData: {
+ managedGuilds: ["665289423482519565"],
+ mxid: "@cadence:cadence.moe"
+ },
+ api: {
+ async getFullHierarchy(spaceID) {
+ return []
+ }
+ }
+ })
+ t.has(html, `Data Horde
`)
+})
+
+// *****
+
+test("web link room: access denied when not logged in to Discord", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/link", {
+ sessionData: {
+ },
+ body: {
+ discord: "665310973967597573",
+ matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
+ guild_id: "665289423482519565"
+ }
+ }))
+ t.equal(error.data, "Can't edit a guild you don't have Manage Server permissions in")
+})
+
+test("web link room: check that guild exists", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/link", {
+ sessionData: {
+ managedGuilds: ["1"]
+ },
+ body: {
+ discord: "665310973967597573",
+ matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
+ guild_id: "1"
+ }
+ }))
+ t.equal(error.data, "Discord guild does not exist or bot has not joined it")
+})
+
+test("web link room: check that channel exists", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/link", {
+ sessionData: {
+ managedGuilds: ["665289423482519565"]
+ },
+ body: {
+ discord: "1",
+ matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
+ guild_id: "665289423482519565"
+ }
+ }))
+ t.equal(error.data, "Discord channel does not exist")
+})
+
+test("web link room: check that channel is part of guild", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/link", {
+ sessionData: {
+ managedGuilds: ["665289423482519565"]
+ },
+ body: {
+ discord: "112760669178241024",
+ matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
+ guild_id: "665289423482519565"
+ }
+ }))
+ t.equal(error.data, "Channel ID 112760669178241024 is not part of guild 665289423482519565")
+})
+
+test("web link room: check that channel is not already linked", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/link", {
+ sessionData: {
+ managedGuilds: ["112760669178241024"]
+ },
+ body: {
+ discord: "112760669178241024",
+ matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
+ guild_id: "112760669178241024"
+ }
+ }))
+ t.equal(error.data, "Channel ID 112760669178241024 or room ID !NDbIqNpJyPvfKRnNcr:cadence.moe are already bridged and cannot be reused")
+})
+
+test("web link room: checks the autocreate setting if the space doesn't exist yet", 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"
+ },
+ createSpace: {
+ async ensureSpace(guild) {
+ called++
+ t.equal(guild.id, "665289423482519565")
+ // simulate what ensureSpace is intended to check
+ const autocreate = 0
+ assert.equal(autocreate, 1, "refusing to implicitly create a space for guild 665289423482519565. set the guild_active data first before calling ensureSpace/syncSpace.")
+ return ""
+ }
+ }
+ }))
+ t.match(error.message, /refusing to implicitly create a space/)
+ t.equal(called, 1)
+})
+
+test("web link room: check that room is part of space (not in hierarchy)", 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 *generateFullHierarchy(spaceID) {
+ called++
+ t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
+ }
+ }
+ }))
+ t.equal(error.data, "Matrix room needs to be part of the bridged space")
+ t.equal(called, 1)
+})
+
+test("web link room: check that bridge can join room (notices lack of via and asks for invite instead)", 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++
+ throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"})
+ },
+ 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 */
+ }
+ }
+ }))
+ t.equal(error.data, "Unable to join the requested Matrix room. Please invite the bridge to the room and try again. (Server said: M_FORBIDDEN - not allowed to join I guess)")
+ t.equal(called, 2)
+})
+
+test("web link room: check that bridge can join room (uses via for join attempt)", 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, _, via) {
+ called++
+ t.deepEqual(via, ["cadence.moe", "hashi.re"])
+ throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"})
+ },
+ 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
+ }
+ yield {
+ room_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
+ children_state: [{
+ type: "m.space.child",
+ state_key: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
+ sender: "@elliu:hashi.re",
+ content: {
+ via: ["cadence.moe", "hashi.re"]
+ },
+ origin_server_ts: 0
+ }],
+ guest_can_join: false,
+ num_joined_members: 2
+ }
+ /* c8 ignore next */
+ }
+ }
+ }))
+ t.equal(error.data, "M_FORBIDDEN - not allowed to join I guess")
+ t.equal(called, 2)
+})
+
+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: {
+ 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, "")
+ 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, 4)
+})
+
+test("web link room: successfully calls createRoom", async t => {
+ let called = 0
+ await 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) {
+ if (type === "m.room.power_levels") {
+ called++
+ t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
+ t.equal(key, "")
+ return {users: {"@_ooye_bot:cadence.moe": 100}}
+ } else if (type === "m.room.name") {
+ called++
+ t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
+ return {}
+ } else if (type === "m.room.avatar") {
+ called++
+ t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
+ return {}
+ } else if (type === "m.room.topic") {
+ called++
+ t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe")
+ 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")
+ t.equal(type, "m.room.message")
+ t.match(content.body, /👋/)
+ return ""
+ }
+ },
+ createRoom: {
+ async syncRoom(channelID) {
+ called++
+ t.equal(channelID, "665310973967597573")
+ return "!NDbIqNpJyPvfKRnNcr:cadence.moe"
+ }
+ }
+ })
+ t.equal(called, 9)
+})
+
+// *****
+
+test("web unlink room: access denied if not logged in to Discord", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/unlink", {
+ body: {
+ channel_id: "665310973967597573",
+ guild_id: "665289423482519565"
+ }
+ }))
+ t.equal(error.data, "Can't edit a guild you don't have Manage Server permissions in")
+})
+
+test("web unlink room: checks that guild exists", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/unlink", {
+ sessionData: {
+ managedGuilds: ["2"]
+ },
+ body: {
+ channel_id: "665310973967597573",
+ guild_id: "2"
+ }
+ }))
+ t.equal(error.data, "Discord guild does not exist or bot has not joined it")
+})
+
+test("web unlink room: checks that the channel is part of the guild", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/unlink", {
+ sessionData: {
+ managedGuilds: ["665289423482519565"]
+ },
+ body: {
+ channel_id: "112760669178241024",
+ guild_id: "665289423482519565"
+ }
+ }))
+ t.equal(error.data, "Channel ID 112760669178241024 is not part of guild 665289423482519565")
+})
+
+test("web unlink room: successfully calls unbridgeChannel when the channel does exist", async t => {
+ let called = 0
+ await router.test("post", "/api/unlink", {
+ sessionData: {
+ managedGuilds: ["665289423482519565"]
+ },
+ body: {
+ channel_id: "665310973967597573",
+ guild_id: "665289423482519565"
+ },
+ createRoom: {
+ async unbridgeChannel(channel) {
+ called++
+ t.equal(channel.id, "665310973967597573")
+ }
+ }
+ })
+ t.equal(called, 1)
+})
+
+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: {
+ managedGuilds: ["112760669178241024"]
+ },
+ body: {
+ channel_id: "489237891895768942",
+ guild_id: "112760669178241024"
+ },
+ createRoom: {
+ async unbridgeChannel(channel) {
+ called++
+ t.equal(channel.id, "489237891895768942")
+ }
+ }
+ })
+ t.equal(called, 1)
+})
+
+test("web unlink room: checks that the channel is bridged", async t => {
+ const row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get()
+ db.prepare("DELETE FROM channel_room WHERE channel_id = '665310973967597573'").run()
+
+ const [error] = await tryToCatch(() => router.test("post", "/api/unlink", {
+ sessionData: {
+ managedGuilds: ["665289423482519565"]
+ },
+ body: {
+ channel_id: "665310973967597573",
+ guild_id: "665289423482519565"
+ }
+ }))
+ t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged")
+
+ db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_id, row.speedbump_checked, row.speedbump_webhook_id, row.guild_id, row.custom_topic)
+ const new_row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get()
+ t.deepEqual(row, new_row)
+})
+
+// *****
+
+test("web unlink space: access denied if not logged in to Discord", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", {
+ body: {
+ guild_id: "665289423482519565"
+ }
+ }))
+ t.equal(error.data, "Can't edit a guild you don't have Manage Server permissions in")
+})
+
+test("web unlink space: checks that guild exists", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", {
+ sessionData: {
+ managedGuilds: ["2"]
+ },
+ body: {
+ guild_id: "2"
+ }
+ }))
+ t.equal(error.data, "Discord guild does not exist or bot has not joined it")
+})
+
+test("web unlink space: checks that a space is linked to the guild before trying to unlink the space", async t => {
+ 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: {
+ managedGuilds: ["665289423482519565"]
+ },
+ body: {
+ guild_id: "665289423482519565"
+ }
+ }))
+ t.equal(error.data, "Discord guild has not been considered for bridging")
+
+ db.exec("ROLLBACK") // ぬ
+})
+
+test("web unlink space: correctly abort unlinking if some linked channels remain after trying to unlink them all", async t => {
+ let unbridgedChannel = false
+
+ const [error] = await tryToCatch(() => router.test("post", "/api/unlink-space", {
+ sessionData: {
+ managedGuilds: ["665289423482519565"]
+ },
+ body: {
+ guild_id: "665289423482519565",
+ },
+ createRoom: {
+ async unbridgeChannel(channel, guildID) {
+ unbridgedChannel = true
+ 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
+ }
+ },
+ api: {
+ async *generateFullHierarchy(spaceID) {
+ 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 */
+ },
+ }
+ }))
+
+ 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 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}`
+
+ const getLinkRowQuery = "SELECT * FROM guild_space WHERE guild_id = '665289423482519565'"
+
+ const row = db.prepare(getLinkRowQuery).get()
+ t.equal(row.space_id, "!zTMspHVUBhFLLSdmnS:cadence.moe")
+
+ let unbridgedChannel = false
+ let downgradedPowerLevel = false
+ let leftRoom = false
+ await router.test("post", "/api/unlink-space", {
+ sessionData: {
+ managedGuilds: ["665289423482519565"]
+ },
+ body: {
+ guild_id: "665289423482519565",
+ },
+ createRoom: {
+ async unbridgeChannel(channel, guildID) {
+ unbridgedChannel = true
+ 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 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: {
+ async *generateFullHierarchy(spaceID) {
+ 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) { // 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(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) {
+ leftRoom = true
+ t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
+ },
+ }
+ })
+
+ t.equal(unbridgedChannel, true)
+ t.equal(downgradedPowerLevel, true)
+ t.equal(leftRoom, true)
+
+ const missed_row = db.prepare(getLinkRowQuery).get()
+ t.equal(missed_row, undefined)
+})
diff --git a/src/web/routes/log-in-with-matrix.js b/src/web/routes/log-in-with-matrix.js
new file mode 100644
index 00000000..d36d8fa4
--- /dev/null
+++ b/src/web/routes/log-in-with-matrix.js
@@ -0,0 +1,100 @@
+// @ts-check
+
+const {z} = require("zod")
+const {randomUUID} = require("crypto")
+const {defineEventHandler, getValidatedQuery, sendRedirect, readValidatedBody, createError, getRequestHeader, H3Event} = require("h3")
+const {LRUCache} = require("lru-cache")
+
+const {as, db, select} = require("../../passthrough")
+const {reg} = require("../../matrix/read-registration")
+
+const {sync} = require("../../passthrough")
+const assert = require("assert").strict
+/** @type {import("../pug-sync")} */
+const pugSync = sync.require("../pug-sync")
+/** @type {import("../auth")} */
+const auth = sync.require("../auth")
+
+const schema = {
+ form: z.object({
+ mxid: z.string().regex(/^@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)$/),
+ next: z.string().optional()
+ }),
+ token: z.object({
+ token: z.string().optional(),
+ next: z.string().optional()
+ })
+}
+
+/**
+ * @param {H3Event} event
+ * @returns {import("../../matrix/api")}
+ */
+function getAPI(event) {
+ /* c8 ignore next */
+ return event.context.api || sync.require("../../matrix/api")
+}
+
+/** @type {LRUCache} token to mxid */
+const validToken = new LRUCache({max: 200})
+
+/*
+ 1st request, GET, they clicked the button, need to input their mxid
+ 2nd request, POST, they input their mxid and we need to send a link
+ 3rd request, GET, they clicked the link and we need to set the session data (just their mxid)
+*/
+
+as.router.get("/log-in-with-matrix", defineEventHandler(async event => {
+ let {token, next} = await getValidatedQuery(event, schema.token.parse)
+
+ if (!token) {
+ // We are in the first request and need to tell them to input their mxid
+ return pugSync.render(event, "log-in-with-matrix.pug", {next})
+ }
+
+ const userAgent = getRequestHeader(event, "User-Agent")
+ if (userAgent?.match(/bot|matrix/)) throw createError({status: 400, data: "Sorry URL previewer, you can't have this URL."})
+
+ if (!validToken.has(token)) return sendRedirect(event, `${reg.ooye.bridge_origin}/log-in-with-matrix`, 302)
+
+ const session = await auth.useSession(event)
+ const mxid = validToken.get(token)
+ assert(mxid)
+ validToken.delete(token)
+
+ await session.update({mxid})
+
+ if (!next) next = "./" // open to homepage where they can see they're logged in
+ return sendRedirect(event, next, 302)
+}))
+
+as.router.post("/api/log-in-with-matrix", defineEventHandler(async event => {
+ const api = getAPI(event)
+ const {mxid, next} = await readValidatedBody(event, schema.form.parse)
+
+ // Don't extend a duplicate invite for the same user
+ for (const alreadyInvited of validToken.values()) {
+ if (mxid === alreadyInvited) {
+ return sendRedirect(event, "../ok?msg=We already sent you a link on Matrix. Please click it!", 302)
+ }
+ }
+
+ const roomID = await api.usePrivateChat(mxid)
+
+ const token = randomUUID()
+
+ console.log(`web log in requested for ${mxid}`)
+ const paramsObject = {token}
+ if (next) paramsObject.next = next
+ const params = new URLSearchParams(paramsObject)
+ let link = `${reg.ooye.bridge_origin}/log-in-with-matrix?${params.toString()}`
+ const body = `Hi, this is Out Of Your Element! You just clicked the "log in" button on the website.\nOpen this link to finish: ${link}\nThe link can be used once.`
+ await api.sendEvent(roomID, "m.room.message", {
+ msgtype: "m.text",
+ body
+ })
+
+ validToken.set(token, mxid)
+
+ return sendRedirect(event, "../ok?msg=Please check your inbox on Matrix!&spot=SpotMailXL", 302)
+}))
diff --git a/src/web/routes/log-in-with-matrix.test.js b/src/web/routes/log-in-with-matrix.test.js
new file mode 100644
index 00000000..830556e3
--- /dev/null
+++ b/src/web/routes/log-in-with-matrix.test.js
@@ -0,0 +1,112 @@
+// @ts-check
+
+const tryToCatch = require("try-to-catch")
+const {router, test} = require("../../../test/web")
+const {MatrixServerError} = require("../../matrix/mreq")
+
+// ***** first request *****
+
+test("log in with matrix: shows web page with form on first request", async t => {
+ const html = await router.test("get", "/log-in-with-matrix", {
+ })
+ t.has(html, `hx-post="api/log-in-with-matrix"`)
+})
+
+// ***** second request *****
+
+let token
+
+test("log in with matrix: checks if mxid format looks valid", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/log-in-with-matrix", {
+ body: {
+ mxid: "x@cadence:cadence.moe"
+ }
+ }))
+ t.match(error.data.fieldErrors.mxid, /must match pattern/)
+})
+
+test("log in with matrix: checks if mxid domain format looks valid", async t => {
+ const [error] = await tryToCatch(() => router.test("post", "/api/log-in-with-matrix", {
+ body: {
+ mxid: "@cadence:cadence."
+ }
+ }))
+ t.match(error.data.fieldErrors.mxid, /must match pattern/)
+})
+
+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",
+ next: "https://bridge.cadence.moe/guild?guild_id=123"
+ },
+ api: {
+ 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-]+)&next=/)[1]
+ t.ok(token, "log in token not issued")
+ return ""
+ }
+ },
+ event
+ })
+ t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/)
+ t.equal(called, 2)
+})
+
+test("log in with matrix: does not send another message when a log in is in progress", async t => {
+ const event = {}
+ await router.test("post", "/api/log-in-with-matrix", {
+ body: {
+ mxid: "@cadence:cadence.moe"
+ },
+ event
+ })
+ t.match(event.node.res.getHeader("location"), /We already sent you a link on Matrix/)
+})
+
+// ***** third request *****
+
+
+test("log in with matrix: does not use up token when requested by Synapse URL previewer", async t => {
+ const event = {}
+ const [error] = await tryToCatch(() => router.test("get", `/log-in-with-matrix?token=${token}`, {
+ headers: {
+ "user-agent": "Synapse (bot; +https://github.com/matrix-org/synapse)"
+ },
+ event
+ }))
+ t.equal(error.data, "Sorry URL previewer, you can't have this URL.")
+})
+
+test("log in with matrix: does not use up token when requested by Discord URL previewer", async t => {
+ const event = {}
+ const [error] = await tryToCatch(() => router.test("get", `/log-in-with-matrix?token=${token}`, {
+ headers: {
+ "user-agent": "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)"
+ },
+ event
+ }))
+ t.equal(error.data, "Sorry URL previewer, you can't have this URL.")
+})
+
+test("log in with matrix: successful request when using valid token", async t => {
+ const event = {}
+ await router.test("get", `/log-in-with-matrix?token=${token}`, {event})
+ t.equal(event.node.res.getHeader("location"), "./")
+})
+
+test("log in with matrix: won't log in again if token has been used", async t => {
+ const event = {}
+ await router.test("get", `/log-in-with-matrix?token=${token}`, {event})
+ t.equal(event.node.res.getHeader("location"), "https://bridge.example.org/log-in-with-matrix")
+})
diff --git a/src/web/routes/oauth.js b/src/web/routes/oauth.js
index f3078ade..f4bb61f5 100644
--- a/src/web/routes/oauth.js
+++ b/src/web/routes/oauth.js
@@ -2,13 +2,15 @@
const {z} = require("zod")
const {randomUUID} = require("crypto")
-const {defineEventHandler, getValidatedQuery, sendRedirect, getQuery, useSession, createError} = require("h3")
-const {SnowTransfer} = require("snowtransfer")
+const {defineEventHandler, getValidatedQuery, sendRedirect, createError, H3Event} = require("h3")
+const {SnowTransfer, tokenless} = require("snowtransfer")
const DiscordTypes = require("discord-api-types/v10")
-const fetch = require("node-fetch")
+const getRelativePath = require("get-relative-path")
-const {as} = 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")
const redirect_uri = `${reg.ooye.bridge_origin}/oauth`
@@ -25,29 +27,49 @@ const schema = {
token: z.object({
token_type: z.string(),
access_token: z.string(),
- expires_in: z.number({coerce: true}),
+ expires_in: z.coerce.number(),
refresh_token: z.string(),
scope: z.string()
})
}
+/**
+ * @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 useSession(event, {password: reg.as_token})
+ const session = await auth.useSession(event)
let scope = "guilds"
- const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse)
- if (parsedFirstQuery.data?.action === "add") {
- scope = "bot+guilds"
- await session.update({selfService: false})
- } else if (parsedFirstQuery.data?.action === "add-self-service") {
- scope = "bot+guilds"
- await session.update({selfService: true})
+ if (!reg.ooye.web_password || reg.ooye.web_password === session.data.password) {
+ const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse)
+ if (parsedFirstQuery.data?.action === "add") {
+ scope = "bot+guilds"
+ await session.update({selfService: false})
+ } else if (parsedFirstQuery.data?.action === "add-self-service") {
+ scope = "bot+guilds"
+ await session.update({selfService: true})
+ }
}
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)
@@ -57,36 +79,26 @@ 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 res = await fetch("https://discord.com/api/oauth2/token", {
- method: "post",
- body: new URLSearchParams({
- grant_type: "authorization_code",
- client_id: id,
- client_secret: reg.ooye.discord_client_secret,
- redirect_uri,
- code: parsedQuery.data.code
- })
- })
- const root = await res.json()
+ const oauthResult = await getOauth2Token(event)(id, redirect_uri, reg.ooye.discord_client_secret, parsedQuery.data.code)
+ const parsedToken = schema.token.parse(oauthResult)
- const parsedToken = schema.token.safeParse(root)
- if (!res.ok || !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(root)}`})
- }
+ const userID = Buffer.from(parsedToken.access_token.split(".")[0], "base64").toString()
+ const client = getClient(event)(parsedToken.access_token)
- const client = new SnowTransfer(`Bearer ${parsedToken.data.access_token}`)
- try {
- const guilds = await client.user.getGuilds()
- const managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id)
- await session.update({managedGuilds})
- } catch (e) {
- throw createError({status: 502, message: "API call failed", data: e.message})
+ 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
+ if (managedGuilds.includes(parsedQuery.data.guild_id)) {
+ const autocreateInteger = +!session.data.selfService
+ db.prepare("INSERT INTO guild_active (guild_id, autocreate) VALUES (?, ?) ON CONFLICT DO UPDATE SET autocreate = ?").run(parsedQuery.data.guild_id, autocreateInteger, autocreateInteger)
}
if (parsedQuery.data.guild_id) {
- // TODO: we probably need to create a matrix space and database entry immediately here so that self-service settings apply and so matrix users can be invited
- return sendRedirect(event, `/guild?guild_id=${parsedQuery.data.guild_id}`, 302)
+ return sendRedirect(event, getRelativePath(event.path, `/guild?guild_id=${parsedQuery.data.guild_id}`), 302)
}
- return sendRedirect(event, "/", 302)
+ return sendRedirect(event, getRelativePath(event.path, "/"), 302)
}))
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.js b/src/web/routes/password.js
new file mode 100644
index 00000000..e1dd2990
--- /dev/null
+++ b/src/web/routes/password.js
@@ -0,0 +1,21 @@
+// @ts-check
+
+const {z} = require("zod")
+const {defineEventHandler, readValidatedBody, sendRedirect} = require("h3")
+const {as, sync} = require("../../passthrough")
+
+/** @type {import("../auth")} */
+const auth = sync.require("../auth")
+
+const schema = {
+ password: z.object({
+ password: z.string()
+ })
+}
+
+as.router.post("/api/password", defineEventHandler(async event => {
+ const {password} = await readValidatedBody(event, schema.password.parse)
+ const session = await auth.useSession(event)
+ await session.update({password})
+ return sendRedirect(event, "../")
+}))
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 387439f1..dc13cf0d 100644
--- a/src/web/server.js
+++ b/src/web/server.js
@@ -1,39 +1,35 @@
// @ts-check
+const assert = require("assert")
const fs = require("fs")
const {join} = require("path")
const h3 = require("h3")
-const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHeader, setResponseStatus, useSession, getQuery, 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("../matrix/utils")} */
+const mUtils = sync.require("../matrix/utils")
const {id} = require("../../addbot")
// Pug
-pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, icons})
-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/invite")
-sync.require("./routes/guild-settings")
-sync.require("./routes/oauth")
+pugSync.addGlobals({id, h3, discord, select, from, DiscordTypes, dUtils, mUtils, icons, reg: reg.reg})
// Files
function compressResponse(event, response) {
if (!getRequestHeader(event, "accept-encoding")?.includes("gzip")) return
+ /* c8 ignore next */
if (typeof response.body !== "string") return
- /** @type {ReadableStream} */ // @ts-ignore
const stream = new Response(response.body).body
+ assert(stream)
setResponseHeader(event, "content-encoding", "gzip")
response.body = stream.pipeThrough(new CompressionStream("gzip"))
}
@@ -47,16 +43,88 @@ as.router.get("/static/stacks.min.css", defineEventHandler({
}
}))
-as.router.get("/static/htmx.min.js", defineEventHandler({
+as.router.get("/static/htmx.js", defineEventHandler({
onBeforeResponse: compressResponse,
handler: async event => {
handleCacheHeaders(event, {maxAge: 86400})
defaultContentType(event, "text/javascript")
- return fs.promises.readFile(join(__dirname, "static", "htmx.min.js"), "utf-8")
+ return fs.promises.readFile(require.resolve("htmx.org/dist/htmx.js"), "utf-8")
}
}))
-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/src/web/server.test.js b/src/web/server.test.js
new file mode 100644
index 00000000..6ed3535c
--- /dev/null
+++ b/src/web/server.test.js
@@ -0,0 +1,37 @@
+// @ts-check
+
+const streamWeb = require("stream/web")
+const {test} = require("../../test/web")
+const {router} = require("../../test/web")
+const assert = require("assert").strict
+
+require("./server")
+
+test("web server: can get home", async t => {
+ t.has(await router.test("get", "/", {}), /a bridge between the Discord and Matrix chat apps/)
+})
+
+test("web server: can get htmx", async t => {
+ t.match(await router.test("get", "/static/htmx.js", {}), /htmx =/)
+})
+
+test("web server: can get css", async t => {
+ t.match(await router.test("get", "/static/stacks.min.css", {}), /--stacks-/)
+})
+
+test("web server: can get icon", async t => {
+ const content = await router.test("get", "/icon.png", {})
+ t.ok(content instanceof Buffer)
+})
+
+test("web server: compresses static resources", async t => {
+ const content = await router.test("get", "/static/stacks.min.css", {
+ headers: {
+ "accept-encoding": "gzip"
+ }
+ })
+ assert(content instanceof streamWeb.ReadableStream)
+ const firstChunk = await content.getReader().read()
+ t.ok(firstChunk.value instanceof Uint8Array, "can get data")
+ t.deepEqual(firstChunk.value.slice(0, 3), Uint8Array.from([31, 139, 8]), "has compressed gzip header")
+})
diff --git a/src/web/static/htmx.min.js b/src/web/static/htmx.min.js
deleted file mode 100644
index c11fbbdf..00000000
--- a/src/web/static/htmx.min.js
+++ /dev/null
@@ -1 +0,0 @@
-var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.2"};Q.onLoad=$;Q.process=Dt;Q.on=be;Q.off=we;Q.trigger=de;Q.ajax=Hn;Q.find=r;Q.findAll=p;Q.closest=g;Q.remove=K;Q.addClass=Y;Q.removeClass=o;Q.toggleClass=W;Q.takeClass=ge;Q.swap=ze;Q.defineExtension=Bn;Q.removeExtension=Un;Q.logAll=z;Q.logNone=J;Q.parseInterval=h;Q._=_;const n={addTriggerHandler:Et,bodyContains:le,canAccessLocalStorage:j,findThisElement:Ee,filterValues:hn,swap:ze,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:T,getExpressionVars:Cn,getHeaders:dn,getInputValues:cn,getInternalData:ie,getSwapSpecification:pn,getTriggerSpecs:lt,getTarget:Ce,makeFragment:D,mergeObjects:ue,makeSettleInfo:xn,oobSwap:Te,querySelectorExt:ae,settleImmediately:Gt,shouldCancel:ht,triggerEvent:de,triggerErrorEvent:fe,withExtensions:Bt};const v=["get","post","put","delete","patch"];const O=v.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");const R=e("head");function e(e,t=false){return new RegExp(`<${e}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${e}>`,t?"gim":"im")}function h(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function H(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function T(e,t){while(e&&!t(e)){e=u(e)}return e||null}function q(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;T(t,function(e){return!!(r=q(t,ce(e),n))});if(r!=="unset"){return r}}function f(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function L(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function N(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function A(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function I(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function P(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function k(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(P(e)){const t=I(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){w(e)}finally{e.remove()}}})}function D(e){const t=e.replace(R,"");const n=L(t);let r;if(n==="html"){r=new DocumentFragment;const i=N(e);A(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=N(t);A(r,i.body);r.title=i.title}else{const i=N(''+t+"");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){k(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function M(e){return typeof e==="function"}function X(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e=0}function le(e){const t=e.getRootNode&&e.getRootNode();if(t&&t instanceof window.ShadowRoot){return ne().body.contains(t.host)}else{return ne().body.contains(e)}}function U(e){return e.trim().split(/\s+/)}function ue(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){w(e);return null}}function j(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function V(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function _(e){return vn(ne().body,function(){return eval(e)})}function $(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function z(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function J(){Q.logger=null}function r(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return r(ne(),e)}}function p(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return p(ne(),e)}}function E(){return window}function K(e,t){e=y(e);if(t){E().setTimeout(function(){K(e);e=null},t)}else{u(e).removeChild(e)}}function ce(e){return e instanceof Element?e:null}function G(e){return e instanceof HTMLElement?e:null}function Z(e){return typeof e==="string"?e:null}function d(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function Y(e,t,n){e=ce(y(e));if(!e){return}if(n){E().setTimeout(function(){Y(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function o(e,t,n){let r=ce(y(e));if(!r){return}if(n){E().setTimeout(function(){o(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function ge(e,t){e=y(e);se(e.parentElement.children,function(e){o(e,t)});Y(ce(e),t)}function g(e,t){e=ce(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||f(e,t)){return e}}while(e=e&&ce(u(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function pe(e,t){return e.substring(e.length-t.length)===t}function i(e){const t=e.trim();if(l(t,"<")&&pe(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(e,t,n){e=y(e);if(t.indexOf("closest ")===0){return[g(ce(e),i(t.substr(8)))]}else if(t.indexOf("find ")===0){return[r(d(e),i(t.substr(5)))]}else if(t==="next"){return[ce(e).nextElementSibling]}else if(t.indexOf("next ")===0){return[me(e,i(t.substr(5)),!!n)]}else if(t==="previous"){return[ce(e).previousElementSibling]}else if(t.indexOf("previous ")===0){return[ye(e,i(t.substr(9)),!!n)]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else if(t==="root"){return[H(e,!!n)]}else if(t.indexOf("global ")===0){return m(e,t.slice(7),true)}else{return F(d(H(e,!!n)).querySelectorAll(i(t)))}}var me=function(t,e,n){const r=d(H(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return r(d(t)||document,e)}else{return e}}function xe(e,t,n){if(M(t)){return{target:ne().body,event:Z(e),listener:t}}else{return{target:y(e),event:Z(t),listener:n}}}function be(t,n,r){_n(function(){const e=xe(t,n,r);e.target.addEventListener(e.event,e.listener)});const e=M(n);return e?n:r}function we(t,n,r){_n(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return M(n)?n:r}const ve=ne().createElement("output");function Se(e,t){const n=re(e,t);if(n){if(n==="this"){return[Ee(e,t)]}else{const r=m(e,n);if(r.length===0){w('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Ee(e,t){return ce(T(e,function(e){return te(ce(e),t)!=null}))}function Ce(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Ee(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Oe(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{s=e}const n=ne().querySelectorAll(t);if(n){se(n,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!He(s,e)){t=d(n)}const r={shouldSwap:true,target:e,fragment:t};if(!de(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){_e(s,e,e,t,i)}se(i.elts,function(e){de(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function qe(e){se(p(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){e.parentNode.replaceChild(n,e)}})}function Le(l,e,u){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=d(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Re(t,i);u.tasks.push(function(){Re(t,s)})}}})}function Ne(e){return function(){o(e,Q.config.addedClass);Dt(ce(e));Ae(d(e));de(e,"htmx:load")}}function Ae(e){const t="[autofocus]";const n=G(f(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function c(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;Y(ce(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ne(o))}}}function Ie(e,t){let n=0;while(n0}function ze(e,t,r,o){if(!o){o={}}e=y(e);const n=document.activeElement;let i={};try{i={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const s=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=D(t);s.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(l,r.settleDelay)}else{l()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(X(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}de(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(nt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function b(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function ot(e){let t;if(e.length>0&&Qe.test(e[0])){e.shift();t=b(e,et).trim();e.shift()}else{t=b(e,x)}return t}const it="input, textarea, select";function st(e,t,n){const r=[];const o=tt(t);do{b(o,We);const l=o.length;const u=b(o,/[,\[\s]/);if(u!==""){if(u==="every"){const c={trigger:"every"};b(o,We);c.pollInterval=h(b(o,/[,\[\s]/));b(o,We);var i=rt(e,o,"event");if(i){c.eventFilter=i}r.push(c)}else{const a={trigger:u};var i=rt(e,o,"event");if(i){a.eventFilter=i}while(o.length>0&&o[0]!==","){b(o,We);const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=h(b(o,x))}else if(f==="from"&&o[0]===":"){o.shift();if(Qe.test(o[0])){var s=ot(o)}else{var s=b(o,x);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const d=ot(o);if(d.length>0){s+=" "+d}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=ot(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=h(b(o,x))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=b(o,x)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=ot(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=b(o,x)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}b(o,We)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function lt(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||st(e,t,r)}if(n.length>0){return n}else if(f(e,"form")){return[{trigger:"submit"}]}else if(f(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(f(e,it)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function ut(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!pt(n,e,Xt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function at(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function ft(e){return g(e,Q.config.disableSelector)}function dt(t,n,e){if(t instanceof HTMLAnchorElement&&at(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";if(r==="get"){}o=ee(t,"action")}e.forEach(function(e){mt(t,function(e,t){const n=ce(e);if(ft(n)){a(n);return}he(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ce(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(f(n,'input[type="submit"], button')&&g(n,"form")!==null){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function gt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function mt(s,l,e,u,c){const a=ie(s);let t;if(u.from){t=m(s,u.from)}else{t=[s]}if(u.changed){t.forEach(function(e){const t=ie(e);t.lastValue=e.value})}se(t,function(o){const i=function(e){if(!le(s)){o.removeEventListener(u.trigger,i);return}if(gt(s,e)){return}if(c||ht(e,s)){e.preventDefault()}if(pt(u,s,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(s)<0){t.handledFor.push(s);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!f(ce(e.target),u.target)){return}}if(u.once){if(a.triggeredOnce){return}else{a.triggeredOnce=true}}if(u.changed){const n=ie(o);const r=o.value;if(n.lastValue===r){return}n.lastValue=r}if(a.delayed){clearTimeout(a.delayed)}if(a.throttle){return}if(u.throttle>0){if(!a.throttle){de(s,"htmx:trigger");l(s,e);a.throttle=E().setTimeout(function(){a.throttle=null},u.throttle)}}else if(u.delay>0){a.delayed=E().setTimeout(function(){de(s,"htmx:trigger");l(s,e)},u.delay)}else{de(s,"htmx:trigger");l(s,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:i,on:o});o.addEventListener(u.trigger,i)})}let yt=false;let xt=null;function bt(){if(!xt){xt=function(){yt=true};window.addEventListener("scroll",xt);setInterval(function(){if(yt){yt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){wt(e)})}},200)}}function wt(e){if(!s(e,"data-hx-revealed")&&B(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){de(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){de(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function St(t,n,e){let i=false;se(v,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){Et(t,e,n,function(e,t){const n=ce(e);if(g(n,Q.config.disableSelector)){a(n);return}he(r,o,n,t)})})}});return i}function Et(r,e,t,n){if(e.trigger==="revealed"){bt();mt(r,n,t,e);wt(ce(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ce(r),n,e)}else{mt(r,n,t,e)}}function Ct(e){const t=ce(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function qt(e){const t=g(ce(e.target),"button, input[type='submit']");const n=Nt(e);if(n){n.lastButtonClicked=t}}function Lt(e){const t=Nt(e);if(t){t.lastButtonClicked=null}}function Nt(e){const t=g(ce(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",qt);e.addEventListener("focusin",qt);e.addEventListener("focusout",Lt)}function It(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function _t(t){if(!j()){return null}t=V(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){de(ne().body,"htmx:historyCacheMissLoad",i);const e=D(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=jt();const r=xn(n);Dn(e.title);Ve(n,t,r);Gt(r.tasks);Ut=o;de(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Yt(e){zt();e=e||location.pathname+location.search;const t=_t(e);if(t){const n=D(t.content);const r=jt();const o=xn(r);Dn(n.title);Ve(r,n,o);Gt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Ut=e;de(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Zt(e)}}}function Wt(e){let t=Se(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Qt(e){let t=Se(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function en(e,t){se(e,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function tn(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function sn(t,n,r,o,i){if(o==null||tn(t,o)){return}else{t.push(o)}if(nn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=F(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=F(o.files)}rn(s,e,n);if(i){ln(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){on(e.name,e.value,n)}else{t.push(e)}if(i){ln(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}rn(t,e,n)})}}function ln(e,t){const n=e;if(n.willValidate){de(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});de(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function un(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){sn(n,o,i,g(e,"form"),l)}sn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const c=s.lastButtonClicked||e;const a=ee(c,"name");rn(a,c.value,o)}const u=Se(e,"hx-include");se(u,function(e){sn(n,r,i,ce(e),l);if(!f(e,"form")){se(d(e).querySelectorAll(it),function(e){sn(n,r,i,e,l)})}});un(r,o);return{errors:i,formData:r,values:An(r)}}function an(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function fn(e){e=Ln(e);let n="";e.forEach(function(e,t){n=an(n,t,e)});return n}function dn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};wn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.substr(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function gn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function pn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!gn(e)){r.show="top"}if(n){const s=U(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=c;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.substr(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const d=l.substr("focus-scroll:".length);r.focusScroll=d=="true"}else if(e==0){r.swapStyle=l}else{w("Unknown modifier in hx-swap: "+l)}}}}return r}function mn(e){return re(e,"hx-encoding")==="multipart/form-data"||f(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function yn(t,n,r){let o=null;Bt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(mn(n)){return un(new FormData,Ln(r))}else{return fn(r)}}}function xn(e){return{tasks:[],elts:[e]}}function bn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ce(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ce(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function wn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.substr(11);t=true}else if(e.indexOf("js:")===0){e=e.substr(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return wn(ce(u(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function Sn(e,t){return wn(e,"hx-vars",true,t)}function En(e,t){return wn(e,"hx-vals",false,t)}function Cn(e){return ue(Sn(e),En(e))}function On(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function Rn(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function C(e,t){return t.test(e.getAllResponseHeaders())}function Hn(e,t,n){e=e.toLowerCase();if(n){if(n instanceof Element||typeof n==="string"){return he(e,t,null,null,{targetOverride:y(n),returnPromise:true})}else{return he(e,t,y(n.source),n.event,{handler:n.handler,headers:n.headers,values:n.values,targetOverride:y(n.target),swapOverride:n.swap,select:n.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Tn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function qn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return de(e,"htmx:validateUrl",ue({url:o,sameHost:r},n))}function Ln(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Nn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(r){return new Proxy(r,{get:function(e,t){if(typeof t==="symbol"){return Reflect.get(e,t)}if(t==="toJSON"){return()=>Object.fromEntries(r)}if(t in e){if(typeof e[t]==="function"){return function(){return r[t].apply(r,arguments)}}else{return e[t]}}const n=r.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Nn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Mn;const X=i.select||null;if(!le(r)){oe(s);return e}const u=i.targetOverride||ce(Ce(r));if(u==null||u==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let c=ie(r);const a=c.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const N=ee(a,"formmethod");if(N!=null){if(N.toLowerCase()!=="dialog"){t=N}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:u,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(de(r,"htmx:confirm",G)===false){oe(s);return e}}let d=r;let h=re(r,"hx-sync");let g=null;let F=false;if(h){const A=h.split(":");const I=A[0].trim();if(I==="this"){d=Ee(r,"hx-sync")}else{d=ce(ae(r,I))}h=(A[1]||"drop").trim();c=ie(d);if(h==="drop"&&c.xhr&&c.abortable!==true){oe(s);return e}else if(h==="abort"){if(c.xhr){oe(s);return e}else{F=true}}else if(h==="replace"){de(d,"htmx:abort")}else if(h.indexOf("queue")===0){const Z=h.split(" ");g=(Z[1]||"last").trim()}}if(c.xhr){if(c.abortable){de(d,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(c.queuedRequests==null){c.queuedRequests=[]}if(g==="first"&&c.queuedRequests.length===0){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(g==="all"){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(g==="last"){c.queuedRequests=[];c.queuedRequests.push(function(){he(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;c.xhr=p;c.abortable=F;const m=function(){c.xhr=null;c.abortable=false;if(c.queuedRequests!=null&&c.queuedRequests.length>0){const e=c.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var y=prompt(B);if(y===null||!de(r,"htmx:prompt",{prompt:y,target:u})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let x=dn(r,u,y);if(t!=="get"&&!mn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=ue(x,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){un(j,Ln(i.values))}const V=Ln(Cn(r));const w=un(j,V);let v=hn(w,r);if(Q.config.getCacheBusterParam&&t==="get"){v.set("org.htmx.cache-buster",ee(u,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=wn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:v,parameters:An(v),unfilteredFormData:w,unfilteredParameters:An(w),headers:x,target:u,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!de(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;x=C.headers;v=Ln(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){de(r,"htmx:validation:halted",C);oe(s);m();return e}const $=n.split("#");const z=$[0];const O=$[1];let R=n;if(E){R=z;const Y=!v.keys().next().done;if(Y){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=fn(v);if(O){R+="#"+O}}}if(!qn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in x){if(x.hasOwnProperty(k)){const W=x[k];On(p,k,W)}}}const H={xhr:p,target:u,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Tn(r);H.pathInfo.responsePath=Rn(p);M(r,H);if(H.keepIndicators!==true){en(T,q)}de(r,"htmx:afterRequest",H);de(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){de(e,"htmx:afterRequest",H);de(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ue({error:e},H));throw e}};p.onerror=function(){en(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){en(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){en(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!de(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Wt(r);var q=Qt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){de(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});de(r,"htmx:beforeSend",H);const J=E?null:yn(p,r,v);p.send(J);return e}function In(e,t){const n=t.xhr;let r=null;let o=null;if(C(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(C(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(C(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const u=re(e,"hx-replace-url");const c=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(u){a="replace";f=u}else if(c){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function Pn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function kn(e){for(var t=0;t0){E().setTimeout(e,y.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ue({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Xn={};function Fn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Bn(e,t){if(t.init){t.init(n)}Xn[e]=ue(Fn(),t)}function Un(e){delete Xn[e]}function jn(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Xn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return jn(ce(u(e)),n,r)}var Vn=false;ne().addEventListener("DOMContentLoaded",function(){Vn=true});function _n(e){if(Vn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function $n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend","")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function Jn(){const e=zn();if(e){Q.config=ue(Q.config,e)}}_n(function(){Jn();$n();let e=ne().body;Dt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Yt();se(t,function(e){de(e,"htmx:restored",{document:ne(),triggerEvent:de})})}else{if(n){n(e)}}};E().setTimeout(function(){de(e,"htmx:load",{});e=null},0)});return Q}();
\ No newline at end of file
diff --git a/start.js b/start.js
index be434f03..39e8ea09 100755
--- a/start.js
+++ b/start.js
@@ -1,6 +1,7 @@
#!/usr/bin/env node
// @ts-check
+const fs = require("fs")
const sqlite = require("better-sqlite3")
const migrate = require("./src/db/migrate")
const HeatSync = require("heatsync")
@@ -9,8 +10,7 @@ const {reg} = require("./src/matrix/read-registration")
const passthrough = require("./src/passthrough")
const db = new sqlite("ooye.db")
-/** @type {import("heatsync").default} */ // @ts-ignore
-const sync = new HeatSync()
+const sync = new HeatSync({watchFunction: fs.watchFile})
Object.assign(passthrough, {sync, db})
@@ -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
new file mode 100644
index 00000000..41300516
--- /dev/null
+++ b/test/addbot.test.js
@@ -0,0 +1,8 @@
+// @ts-check
+
+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=2251801424568320 `)
+})
diff --git a/test/data.js b/test/data.js
index c8217c27..6a53cb01 100644
--- a/test/data.js
+++ b/test/data.js
@@ -18,15 +18,95 @@ module.exports = {
id: "112760669178241024",
default_thread_rate_limit_per_user: 0,
guild_id: "112760669178241024"
+ },
+ updates: {
+ type: 0,
+ topic: "Updates and release announcements for Out Of Your Element.",
+ rate_limit_per_user: 0,
+ position: 0,
+ permission_overwrites: [{
+ type: 0,
+ id: "112760669178241024",
+ deny: "2048",
+ allow: "0"
+ }],
+ parent_id: null,
+ nsfw: false,
+ name: "updates",
+ last_message_id: "1329413270196715564",
+ id: "1161864271370666075",
+ guild_id: "112760669178241024"
+ },
+ /** @type {DiscordTypes.APITextChannel} */
+ saving_the_world: {
+ type: 0,
+ topic: "Anything and everything archiving/preservation related",
+ rate_limit_per_user: 0,
+ position: 0,
+ permission_overwrites: [
+ {
+ id: "665289423482519565",
+ type: DiscordTypes.OverwriteType.Role,
+ allow: "0",
+ deny: String(DiscordTypes.PermissionFlagsBits.SendMessages)
+ },
+ {
+ id: "684524730274807911",
+ type: DiscordTypes.OverwriteType.Role,
+ allow: String(DiscordTypes.PermissionFlagsBits.SendMessages),
+ deny: "0"
+ }
+ ],
+ parent_id: null,
+ name: "saving-the-world",
+ last_pin_timestamp: "2021-04-14T18:39:41+00:00",
+ last_message_id: "1335828749479837750",
+ id: "665310973967597573",
+ guild_id: "665289423482519565"
+ },
+ character_art: {
+ version: 1749274266694,
+ type: 0,
+ topic: null,
+ rate_limit_per_user: 0,
+ position: 22,
+ permission_overwrites: [
+ {
+ type: 0,
+ id: "1235396773510647810",
+ deny: "0",
+ allow: "3072"
+ },
+ {
+ type: 0,
+ id: "1236581109391949875",
+ deny: "0",
+ allow: "0"
+ },
+ {
+ type: 0,
+ id: "1234728422044074064",
+ deny: "3072",
+ allow: "309237645312"
+ }
+ ],
+ parent_id: "1234730744291528714",
+ nsfw: false,
+ name: "character-art",
+ last_message_id: "1384358176106872924",
+ id: "1235072132095021096",
+ flags: 0,
+ guild_id: "1234728422044074064"
}
},
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"},
"m.room.history_visibility/": {history_visibility: "shared"},
- "m.space.parent/!jjWAGMeQdNrVZSSfvz:cadence.moe": {
+ "m.space.parent/!jjmvBegULiLucuWEHU:cadence.moe": {
via: ["cadence.moe"],
canonical: true
},
@@ -34,21 +114,25 @@ module.exports = {
join_rule: "restricted",
allow: [{
type: "m.room_membership",
- room_id: "!jjWAGMeQdNrVZSSfvz:cadence.moe"
+ room_id: "!jjmvBegULiLucuWEHU:cadence.moe"
}]
},
"m.room.avatar/": {
url: {$url: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024"}
},
"m.room.power_levels/": {
+ events_default: 0,
+ events: {
+ "m.reaction": 0,
+ "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": {hidden: true},
"uk.half-shot.bridge/moe.cadence.ooye://discord/112760669178241024/112760669178241024": {
bridgebot: "@_ooye_bot:cadence.moe",
protocol: {
@@ -58,7 +142,7 @@ module.exports = {
network: {
id: "112760669178241024",
displayname: "Psychonauts 3",
- avatar_url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF"
+ avatar_url: {$url: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024"}
},
channel: {
id: "112760669178241024",
@@ -69,6 +153,7 @@ module.exports = {
}
},
guild: {
+ /** @type {DiscordTypes.APIGuild} */ // @ts-ignore
general: {
owner_id: "112760500130975744",
premium_tier: 3,
@@ -95,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: [],
@@ -121,7 +239,7 @@ module.exports = {
unicode_emoji: null,
tags: {},
position: 0,
- permissions: '559623605575360',
+ permissions: '1122573558996672',
name: '@everyone',
mentionable: false,
managed: false,
@@ -170,6 +288,25 @@ module.exports = {
hoist: true,
flags: 0,
color: 16745267
+ }, {
+ version: 1743122443142,
+ unicode_emoji: null,
+ tags: {},
+ position: 3,
+ permissions: "0",
+ name: "Realdditors",
+ mentionable: true,
+ managed: false,
+ id: "1182745800661540927",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 16729344
+ },
+ color: 16729344
}
],
discovery_splash: null,
@@ -189,6 +326,784 @@ module.exports = {
max_stage_video_channel_users: 300,
system_channel_flags: 0|0,
safety_alerts_channel_id: null
+ },
+ fna: {
+ application_id: null,
+ roles: [],
+ activity_instances: [],
+ banner: null,
+ stickers: [],
+ joined_at: "2020-04-25T07:36:09.644000+00:00",
+ default_message_notifications: 1,
+ afk_timeout: 60,
+ clan: null,
+ hub_type: null,
+ afk_channel_id: "216367750216548362",
+ discovery_splash: null,
+ splash: null,
+ explicit_content_filter: 0,
+ max_members: 500000,
+ premium_subscription_count: 0,
+ voice_states: [],
+ id: "66192955777486848",
+ premium_tier: 0,
+ name: "Function & Arg",
+ premium_progress_bar_enabled: false,
+ icon: "8bfeb3237cd8697d1d1cd5c626ca8cea",
+ large: true,
+ verification_level: 0,
+ public_updates_channel_id: null,
+ stage_instances: [],
+ rules_channel_id: null,
+ emojis: [],
+ owner_id: "66186356581208064",
+ threads: [],
+ max_stage_video_channel_users: 50,
+ description: null,
+ unavailable: false,
+ features: [
+ "CHANNEL_ICON_EMOJIS_GENERATED",
+ "NEW_THREAD_PERMISSIONS",
+ "THREADS_ENABLED",
+ "SOUNDBOARD"
+ ],
+ latest_onboarding_question_id: null,
+ max_video_channel_users: 25,
+ home_header: null,
+ mfa_level: 0,
+ system_channel_id: null,
+ guild_scheduled_events: [],
+ nsfw_level: 0,
+ vanity_url_code: null,
+ member_count: 966,
+ presences: [],
+ application_command_counts: {},
+ system_channel_flags: 0,
+ preferred_locale: "en-US",
+ region: "deprecated",
+ inventory_settings: null,
+ soundboard_sounds: [],
+ version: 1711491959939,
+ incidents_data: null,
+ embedded_activities: [],
+ nsfw: false,
+ safety_alerts_channel_id: null,
+ lazy: true
+ },
+ data_horde: {
+ preferred_locale: "en-US",
+ afk_channel_id: null,
+ profile: null,
+ owner_id: "222343226990788609",
+ soundboard_sounds: [],
+ hub_type: null,
+ mfa_level: 0,
+ activity_instances: [],
+ inventory_settings: null,
+ voice_states: [],
+ system_channel_id: "675397790204952636",
+ id: "665289423482519565",
+ member_count: 138,
+ clan: null,
+ default_message_notifications: 1,
+ name: "Data Horde",
+ banner: null,
+ premium_subscription_count: 0,
+ max_stage_video_channel_users: 50,
+ max_members: 500000,
+ incidents_data: null,
+ joined_at: "2020-05-10T02:00:10.646000+00:00",
+ unavailable: false,
+ discovery_splash: null,
+ threads: [],
+ system_channel_flags: 0,
+ safety_alerts_channel_id: null,
+ nsfw: false,
+ nsfw_level: 0,
+ stage_instances: [],
+ large: false,
+ icon: "d7c4bdb35c10f21e475a50fb205d5c32",
+ roles: [
+ {
+ version: 1683238686112,
+ unicode_emoji: null,
+ tags: {},
+ position: 0,
+ permissions: "968619318849",
+ name: "@everyone",
+ mentionable: false,
+ managed: false,
+ id: "665289423482519565",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ color: 0
+ },
+ {
+ version: 1683791258594,
+ unicode_emoji: null,
+ tags: {},
+ position: 22,
+ permissions: "7515668211",
+ name: "Founder",
+ mentionable: true,
+ managed: false,
+ id: "665290147377578005",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ color: 1752220
+ },
+ {
+ version: 1683791258594,
+ unicode_emoji: null,
+ tags: {},
+ position: 22,
+ permissions: "8194",
+ name: "Moderator",
+ mentionable: true,
+ managed: false,
+ id: "682789592390281245",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ color: 1752220
+ },
+ {
+ version: 1683791258580,
+ unicode_emoji: null,
+ tags: {},
+ position: 19,
+ permissions: "6546775617",
+ name: "Gaming Alexandria",
+ mentionable: false,
+ managed: false,
+ id: "684524730274807911",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ color: 15844367
+ }
+ ],
+ description: null,
+ afk_timeout: 300,
+ verification_level: 1,
+ latest_onboarding_question_id: null,
+ guild_scheduled_events: [],
+ rules_channel_id: null,
+ embedded_activities: [],
+ region: "deprecated",
+ vanity_url_code: null,
+ application_id: null,
+ premium_tier: 0,
+ explicit_content_filter: 0,
+ stickers: [],
+ public_updates_channel_id: null,
+ splash: null,
+ premium_progress_bar_enabled: false,
+ features: [],
+ lazy: true,
+ max_video_channel_users: 25,
+ application_command_counts: {},
+ home_header: null,
+ version: 1717720047590,
+ emojis: [],
+ presences: []
+ },
+ pathfinder: {
+ activity_instances: [],
+ max_video_channel_users: 25,
+ mfa_level: 0,
+ owner_id: "182266888003256320",
+ stage_instances: [],
+ profile: null,
+ rules_channel_id: null,
+ splash: null,
+ inventory_settings: null,
+ max_members: 25000000,
+ icon: "ec42ae174a7c246568da98983b611f64",
+ safety_alerts_channel_id: null,
+ latest_onboarding_question_id: null,
+ id: "1234728422044074064",
+ name: "Hub Pathfinder",
+ embedded_activities: [],
+ banner: null,
+ hub_type: null,
+ threads: [],
+ lazy: true,
+ system_channel_id: "1234728422475829318",
+ member_count: 21,
+ region: "deprecated",
+ description: null,
+ premium_features: null,
+ verification_level: 0,
+ unavailable: false,
+ stickers: [],
+ application_command_counts: {},
+ roles: [
+ {
+ version: 1741255049095,
+ unicode_emoji: null,
+ tags: {},
+ position: 0,
+ permissions: "2173706675146305",
+ name: "@everyone",
+ mentionable: false,
+ managed: false,
+ id: "1234728422044074064",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: { tertiary_color: null, secondary_color: null, primary_color: 0 },
+ color: 0
+ },
+ {
+ version: 1749271325117,
+ unicode_emoji: null,
+ tags: { bot_id: "684280192553844747" },
+ position: 8,
+ permissions: "1610883072",
+ name: "Matrix Bridge",
+ mentionable: false,
+ managed: true,
+ id: "1235117664326783049",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: { tertiary_color: null, secondary_color: null, primary_color: 0 },
+ color: 0
+ },
+ {
+ version: 1749271325132,
+ unicode_emoji: null,
+ tags: {},
+ position: 12,
+ permissions: "0",
+ name: "Tuesday",
+ mentionable: false,
+ managed: false,
+ id: "1235396773510647810",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: { tertiary_color: null, secondary_color: null, primary_color: 0 },
+ color: 0
+ },
+ {
+ version: 1749271325129,
+ unicode_emoji: null,
+ tags: {},
+ position: 11,
+ permissions: "0",
+ name: "Thursday",
+ mentionable: false,
+ managed: false,
+ id: "1235397020919926844",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: { tertiary_color: null, secondary_color: null, primary_color: 0 },
+ color: 0
+ },
+ {
+ version: 1749271325174,
+ unicode_emoji: null,
+ tags: {},
+ position: 20,
+ permissions: "0",
+ name: "Fighter",
+ mentionable: false,
+ managed: false,
+ id: "1236579627615518720",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 12657443
+ },
+ color: 12657443
+ },
+ {
+ version: 1749271325189,
+ unicode_emoji: null,
+ tags: {},
+ position: 24,
+ permissions: "0",
+ name: "Bard",
+ mentionable: false,
+ managed: false,
+ id: "1236579780544036904",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 12468701
+ },
+ color: 12468701
+ },
+ {
+ version: 1749271325179,
+ unicode_emoji: null,
+ tags: {},
+ position: 22,
+ permissions: "0",
+ name: "Cleric",
+ mentionable: false,
+ managed: false,
+ id: "1236579861997555763",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 14186005
+ },
+ color: 14186005
+ },
+ {
+ version: 1749271325138,
+ unicode_emoji: null,
+ tags: {},
+ position: 14,
+ permissions: "0",
+ name: "Wizard",
+ mentionable: false,
+ managed: false,
+ id: "1236579900731822110",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 3106806
+ },
+ color: 3106806
+ },
+ {
+ version: 1749271325176,
+ unicode_emoji: null,
+ tags: {},
+ position: 21,
+ permissions: "0",
+ name: "Druid",
+ mentionable: false,
+ managed: false,
+ id: "1236579988254232606",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 8248698
+ },
+ color: 8248698
+ },
+ {
+ version: 1749271325147,
+ unicode_emoji: null,
+ tags: {},
+ position: 15,
+ permissions: "0",
+ name: "Witch",
+ mentionable: false,
+ managed: false,
+ id: "1236580304232255581",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 1737848
+ },
+ color: 1737848
+ },
+ {
+ version: 1749271325206,
+ unicode_emoji: null,
+ tags: {},
+ position: 28,
+ permissions: "8",
+ name: "DM",
+ mentionable: false,
+ managed: false,
+ id: "1236581109391949875",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 6507441
+ },
+ color: 6507441
+ },
+ {
+ version: 1749271325156,
+ unicode_emoji: null,
+ tags: {},
+ position: 17,
+ permissions: "0",
+ name: "Ranger",
+ mentionable: false,
+ managed: false,
+ id: "1240571725914312825",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 2067276
+ },
+ color: 2067276
+ },
+ {
+ version: 1749271325151,
+ unicode_emoji: null,
+ tags: {},
+ position: 16,
+ permissions: "0",
+ name: "Rogue",
+ mentionable: false,
+ managed: false,
+ id: "1249165855632265267",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 9936031
+ },
+ color: 9936031
+ },
+ {
+ version: 1749271325123,
+ unicode_emoji: null,
+ tags: {},
+ position: 10,
+ permissions: "0",
+ name: "Questions Ping!",
+ mentionable: false,
+ managed: false,
+ id: "1249167820571541534",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 13297400
+ },
+ color: 13297400
+ },
+ {
+ version: 1749271325198,
+ unicode_emoji: null,
+ tags: {},
+ position: 25,
+ permissions: "0",
+ name: "Barbarian",
+ mentionable: false,
+ managed: false,
+ id: "1344484288241991730",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 8145454
+ },
+ color: 8145454
+ },
+ {
+ version: 1749271325200,
+ unicode_emoji: null,
+ tags: {},
+ position: 26,
+ permissions: "0",
+ name: "Alchemist",
+ mentionable: false,
+ managed: false,
+ id: "1352190431944900628",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 15844367
+ },
+ color: 15844367
+ },
+ {
+ version: 1749271325168,
+ unicode_emoji: null,
+ tags: {},
+ position: 19,
+ permissions: "0",
+ name: "Investigator",
+ mentionable: false,
+ managed: false,
+ id: "1353890353391866028",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 10068223
+ },
+ color: 10068223
+ },
+ {
+ version: 1749271325134,
+ unicode_emoji: null,
+ tags: {},
+ position: 13,
+ permissions: "0",
+ name: "Monday",
+ mentionable: false,
+ managed: false,
+ id: "1359752622130593802",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: { tertiary_color: null, secondary_color: null, primary_color: 0 },
+ color: 0
+ },
+ {
+ version: 1749271325162,
+ unicode_emoji: null,
+ tags: {},
+ position: 18,
+ permissions: "0",
+ name: "Monk",
+ mentionable: false,
+ managed: false,
+ id: "1359753361963880590",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 3447003
+ },
+ color: 3447003
+ },
+ {
+ version: 1749271325183,
+ unicode_emoji: null,
+ tags: {},
+ position: 23,
+ permissions: "0",
+ name: "Champion",
+ mentionable: false,
+ managed: false,
+ id: "1359753472186122320",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 15277667
+ },
+ color: 15277667
+ },
+ {
+ version: 1749271325114,
+ unicode_emoji: null,
+ tags: { bot_id: "431544605209788416" },
+ position: 7,
+ permissions: "275415166016",
+ name: "Tupperbox",
+ mentionable: false,
+ managed: true,
+ id: "1377128320814153862",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: { tertiary_color: null, secondary_color: null, primary_color: 0 },
+ color: 0
+ },
+ {
+ version: 1749271325120,
+ unicode_emoji: null,
+ tags: {},
+ position: 9,
+ permissions: "0",
+ name: "PbD ping",
+ mentionable: false,
+ managed: false,
+ id: "1377139953510907995",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: { tertiary_color: null, secondary_color: null, primary_color: 0 },
+ color: 0
+ },
+ {
+ version: 1749271325109,
+ unicode_emoji: null,
+ tags: { bot_id: "644942473315090434" },
+ position: 6,
+ permissions: "535529122897",
+ name: "RPG Sage",
+ mentionable: false,
+ managed: true,
+ id: "1377144599310503959",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: { tertiary_color: null, secondary_color: null, primary_color: 0 },
+ color: 0
+ },
+ {
+ version: 1749271325106,
+ unicode_emoji: null,
+ tags: { bot_id: "572698679618568193" },
+ position: 5,
+ permissions: "278528",
+ name: "Dicecord",
+ mentionable: false,
+ managed: true,
+ id: "1378726921990307974",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: { tertiary_color: null, secondary_color: null, primary_color: 0 },
+ color: 0
+ },
+ {
+ version: 1749271325203,
+ unicode_emoji: null,
+ tags: { bot_id: "443545183997657120" },
+ position: 27,
+ permissions: "2097540216",
+ name: "ChannelBot",
+ mentionable: false,
+ managed: true,
+ id: "1380744875108204658",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: { tertiary_color: null, secondary_color: null, primary_color: 0 },
+ color: 0
+ },
+ {
+ version: 1749271325101,
+ unicode_emoji: null,
+ tags: {},
+ position: 4,
+ permissions: "0",
+ name: "Play-by-Discord",
+ mentionable: false,
+ managed: false,
+ id: "1380748596537720872",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 16377559
+ },
+ color: 16377559
+ },
+ {
+ version: 1749271325098,
+ unicode_emoji: null,
+ tags: {},
+ position: 3,
+ permissions: "0",
+ name: "Boredom Busters",
+ mentionable: false,
+ managed: false,
+ id: "1380756348190462015",
+ icon: null,
+ hoist: false,
+ flags: 0,
+ colors: {
+ tertiary_color: null,
+ secondary_color: null,
+ primary_color: 14542591
+ },
+ color: 14542591
+ },
+ {
+ version: 1749271361998,
+ unicode_emoji: null,
+ tags: {},
+ position: 1,
+ permissions: "0",
+ name: "Bots",
+ mentionable: false,
+ managed: false,
+ id: "1380767647578460311",
+ icon: null,
+ hoist: true,
+ flags: 0,
+ colors: { tertiary_color: null, secondary_color: null, primary_color: 0 },
+ color: 0
+ },
+ {
+ version: 1749271362001,
+ unicode_emoji: null,
+ tags: {},
+ position: 2,
+ permissions: "0",
+ name: "Players",
+ mentionable: false,
+ managed: false,
+ id: "1380768596929806356",
+ icon: null,
+ hoist: true,
+ flags: 0,
+ colors: { tertiary_color: null, secondary_color: null, primary_color: 0 },
+ color: 0
+ }
+ ],
+ vanity_url_code: null,
+ afk_timeout: 300,
+ premium_tier: 0,
+ joined_at: "2024-05-01T06:36:38.605000+00:00",
+ public_updates_channel_id: null,
+ premium_subscription_count: 0,
+ soundboard_sounds: [],
+ home_header: null,
+ discovery_splash: null,
+ guild_scheduled_events: [],
+ system_channel_flags: 0,
+ preferred_locale: "en-US",
+ large: false,
+ explicit_content_filter: 0,
+ moderator_reporting: null,
+ features: [
+ "TIERLESS_BOOSTING_SYSTEM_MESSAGE",
+ "ACTIVITY_FEED_DISABLED_BY_USER"
+ ],
+ version: 1750145431881,
+ owner_configured_content_level: null,
+ voice_states: [],
+ default_message_notifications: 1,
+ application_id: null,
+ incidents_data: null,
+ nsfw_level: 0,
+ premium_progress_bar_enabled: false,
+ afk_channel_id: null,
+ max_stage_video_channel_users: 50,
+ nsfw: false
}
},
user: {
@@ -206,6 +1121,19 @@ module.exports = {
global_name: "Clyde",
avatar_decoration_data: null,
banner_color: null
+ },
+ jerassicore: {
+ username: "ser_jurassicore",
+ public_flags: 0,
+ primary_guild: null,
+ id: "493801948345139202",
+ global_name: "Jurassicore",
+ display_name_styles: null,
+ discriminator: "0",
+ collectibles: null,
+ clan: null,
+ avatar_decoration_data: null,
+ avatar: "2a4fa0de3aaea30f457ed7bba64176aa"
}
},
member: {
@@ -328,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
@@ -503,6 +1433,63 @@ module.exports = {
attachments: [],
guild_id: "112760669178241024"
},
+ simple_room_link: {
+ type: 0,
+ tts: false,
+ timestamp: "2023-07-10T20:04:25.939000+00:00",
+ referenced_message: null,
+ pinned: false,
+ nonce: "1128054139385806848",
+ mentions: [],
+ mention_roles: [],
+ mention_everyone: false,
+ member: {
+ roles: [
+ "112767366235959296", "118924814567211009",
+ "204427286542417920", "199995902742626304",
+ "222168467627835392", "238028326281805825",
+ "259806643414499328", "265239342648131584",
+ "271173313575780353", "287733611912757249",
+ "225744901915148298", "305775031223320577",
+ "318243902521868288", "348651574924541953",
+ "349185088157777920", "378402925128712193",
+ "392141548932038658", "393912152173576203",
+ "482860581670486028", "495384759074160642",
+ "638988388740890635", "373336013109461013",
+ "530220455085473813", "454567553738473472",
+ "790724320824655873", "1123518980456452097",
+ "1040735082610167858", "695946570482450442",
+ "1123460940935991296", "849737964090556488"
+ ],
+ premium_since: null,
+ pending: false,
+ nick: null,
+ mute: false,
+ joined_at: "2015-11-11T09:55:40.321000+00:00",
+ flags: 0,
+ deaf: false,
+ communication_disabled_until: null,
+ avatar: null
+ },
+ id: "1128054143064494233",
+ flags: 0,
+ embeds: [],
+ edited_timestamp: null,
+ content: "https://discord.com/channels/112760669178241024/1100319550446252084",
+ components: [],
+ channel_id: "266767590641238027",
+ author: {
+ username: "kumaccino",
+ public_flags: 128,
+ id: "113340068197859328",
+ global_name: "kumaccino",
+ discriminator: "0",
+ avatar_decoration: null,
+ avatar: "b48302623a12bc7c59a71328f72ccb39"
+ },
+ attachments: [],
+ guild_id: "112760669178241024"
+ },
nicked_room_mention: {
type: 0,
tts: false,
@@ -941,6 +1928,93 @@ module.exports = {
components: []
}
},
+ reply_to_unknown_message: {
+ type: 19,
+ content: "enigmatic",
+ mentions: [
+ {
+ id: "1060361805152669766",
+ username: "occimyy",
+ avatar: "009d2bf557bca7d4f5a1d5b75a4e2eea",
+ discriminator: "0",
+ public_flags: 0,
+ flags: 0,
+ banner: null,
+ accent_color: null,
+ global_name: "Lily",
+ avatar_decoration_data: null,
+ banner_color: null,
+ clan: null,
+ primary_guild: null
+ }
+ ],
+ mention_roles: [],
+ attachments: [],
+ embeds: [],
+ timestamp: "2025-02-22T23:34:14.036000+00:00",
+ edited_timestamp: null,
+ flags: 0,
+ components: [],
+ id: "1343002945670746173",
+ channel_id: "392141322863116319",
+ author: {
+ id: "114147806469554185",
+ username: "extremity",
+ avatar: "0c73816563bf912ccebf1a0f1546cfe4",
+ discriminator: "0",
+ public_flags: 768,
+ flags: 768,
+ banner: null,
+ accent_color: null,
+ global_name: null,
+ 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: "392141322863116319",
+ message_id: "1342606571380674560",
+ guild_id: "112760669178241024"
+ },
+ position: 0,
+ referenced_message: {
+ type: 0,
+ content: "BILLY BOB THE GREAT",
+ mentions: [],
+ mention_roles: [],
+ attachments: [],
+ embeds: [],
+ timestamp: "2025-02-21T21:19:11.041000+00:00",
+ edited_timestamp: null,
+ flags: 0,
+ components: [],
+ id: "1342606571380674560",
+ channel_id: "392141322863116319",
+ author: {
+ id: "1060361805152669766",
+ username: "occimyy",
+ avatar: "009d2bf557bca7d4f5a1d5b75a4e2eea",
+ discriminator: "0",
+ public_flags: 0,
+ flags: 0,
+ banner: null,
+ accent_color: null,
+ global_name: "Occimyy",
+ avatar_decoration_data: null,
+ banner_color: null,
+ clan: null,
+ primary_guild: null
+ },
+ pinned: false,
+ mention_everyone: false,
+ tts: false
+ }
+ },
attachment_no_content: {
id: "1124628646670389348",
type: 0,
@@ -1351,6 +2425,95 @@ module.exports = {
attachments: [],
guild_id: "112760669178241024"
},
+ reply_to_matrix_user_mention: {
+ type: 19,
+ content: "kys",
+ mentions: [],
+ mention_roles: [],
+ attachments: [],
+ embeds: [],
+ timestamp: "2025-08-04T05:31:26.506000+00:00",
+ edited_timestamp: null,
+ flags: 0,
+ components: [],
+ id: "1401799674192723998",
+ channel_id: "112760669178241024",
+ author: {
+ id: "114147806469554185",
+ username: "extremity",
+ avatar: "e0394d500407a8fa93774e1835b8b03a",
+ discriminator: "0",
+ public_flags: 0,
+ flags: 0,
+ banner: null,
+ accent_color: null,
+ global_name: "Extremity",
+ 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,
+ message_reference: {
+ type: 0,
+ channel_id: "112760669178241024",
+ message_id: "1401760355339862066",
+ guild_id: "112760669178241024"
+ },
+ referenced_message: {
+ type: 0,
+ content: "<@114147806469554185> you owe me $30",
+ mentions: [
+ {
+ id: "114147806469554185",
+ username: "extremity",
+ avatar: "e0394d500407a8fa93774e1835b8b03a",
+ discriminator: "0",
+ public_flags: 0,
+ flags: 0,
+ banner: null,
+ accent_color: null,
+ global_name: "Extremity",
+ avatar_decoration_data: null,
+ collectibles: null,
+ display_name_styles: null,
+ banner_color: null,
+ clan: null,
+ primary_guild: null
+ }
+ ],
+ mention_roles: [],
+ attachments: [],
+ embeds: [],
+ timestamp: "2025-08-04T02:55:12.161000+00:00",
+ edited_timestamp: null,
+ flags: 0,
+ components: [],
+ id: "1401760355339862066",
+ channel_id: "112760669178241024",
+ author: {
+ id: "1152700216189911081",
+ username: "okay 🤍 yay 🤍",
+ avatar: "90bc1d6912252d4fa9f92a2f5f6d347b",
+ discriminator: "0000",
+ public_flags: 0,
+ flags: 0,
+ bot: true,
+ global_name: null,
+ clan: null,
+ primary_guild: null
+ },
+ pinned: false,
+ mention_everyone: false,
+ tts: false,
+ application_id: "684280192553844747",
+ webhook_id: "1152700216189911081"
+ }
+ },
reply_with_video: {
id: "1197621094983676007",
type: 19,
@@ -1529,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,
@@ -2015,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,
@@ -2129,7 +3364,493 @@ module.exports = {
mention_everyone: false,
tts: false
}
- }
+ },
+ forwarded_image: { type: 0,
+ content: "",
+ mentions: [],
+ mention_roles: [],
+ attachments: [],
+ embeds: [],
+ timestamp: "2024-10-16T22:25:01.973000+00:00",
+ edited_timestamp: null,
+ flags: 16384,
+ components: [],
+ id: "1296237495993892916",
+ channel_id: "112760669178241024",
+ author: {
+ id: "113340068197859328",
+ username: "kumaccino",
+ avatar: "a8829abe66866d7797b36f0bfac01086",
+ discriminator: "0",
+ public_flags: 128,
+ flags: 128,
+ banner: null,
+ accent_color: null,
+ global_name: "kumaccino",
+ avatar_decoration_data: null,
+ banner_color: null,
+ clan: null
+ },
+ pinned: false,
+ mention_everyone: false,
+ tts: false,
+ message_reference: {
+ type: 1,
+ channel_id: "1019762340922663022",
+ message_id: "1019779830469894234"
+ },
+ position: 0,
+ message_snapshots: [
+ {
+ message: {
+ type: 0,
+ content: "",
+ mentions: [],
+ mention_roles: [],
+ attachments: [
+ {
+ id: "1296237494987133070",
+ filename: "100km.gif",
+ size: 2965649,
+ url: "https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", proxy_url: "https://media.discordapp.net/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", width: 300,
+ height: 300,
+ content_type: "image/gif"
+ }
+ ],
+ embeds: [],
+ timestamp: "2022-09-15T01:20:58.177000+00:00",
+ edited_timestamp: null,
+ flags: 0,
+ components: []
+ }
+ }
+ ]
+ },
+ constructed_forwarded_message: { type: 0,
+ content: "",
+ mentions: [],
+ mention_roles: [],
+ attachments: [],
+ embeds: [],
+ timestamp: "2024-10-16T22:25:01.973000+00:00",
+ edited_timestamp: null,
+ flags: 16384,
+ components: [],
+ id: "1296237495993892916",
+ channel_id: "112760669178241024",
+ author: {
+ id: "113340068197859328",
+ username: "kumaccino",
+ avatar: "a8829abe66866d7797b36f0bfac01086",
+ discriminator: "0",
+ public_flags: 128,
+ flags: 128,
+ banner: null,
+ accent_color: null,
+ global_name: "kumaccino",
+ avatar_decoration_data: null,
+ banner_color: null,
+ clan: null
+ },
+ pinned: false,
+ mention_everyone: false,
+ tts: false,
+ message_reference: {
+ type: 1,
+ channel_id: "176333891320283136",
+ message_id: "1191567971970191490"
+ },
+ position: 0,
+ message_snapshots: [
+ {
+ message: {
+ type: 0,
+ content: "What's cooking, good looking? <:hipposcope:393635038903926784>",
+ mentions: [],
+ mention_roles: [],
+ attachments: [
+ {
+ id: "1296237494987133070",
+ filename: "100km.gif",
+ size: 2965649,
+ url: "https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", proxy_url: "https://media.discordapp.net/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", width: 300,
+ height: 300,
+ content_type: "image/gif"
+ }
+ ],
+ embeds: [{
+ type: "rich",
+ title: "This man is 100 km away from your house",
+ author: {
+ name: "This man"
+ },
+ fields: [{
+ name: "Distance away",
+ value: "99 km"
+ }, {
+ name: "Distance away",
+ value: "98 km"
+ }]
+ }],
+ timestamp: "2022-09-15T01:20:58.177000+00:00",
+ edited_timestamp: null,
+ flags: 0,
+ components: []
+ }
+ }
+ ]
+ },
+ constructed_forwarded_text: { type: 0,
+ content: "What's cooking everybody ‼️",
+ mentions: [],
+ mention_roles: [],
+ attachments: [],
+ embeds: [],
+ timestamp: "2024-10-16T22:25:01.973000+00:00",
+ edited_timestamp: null,
+ flags: 16384,
+ components: [],
+ id: "1296237495993892916",
+ channel_id: "112760669178241024",
+ author: {
+ id: "113340068197859328",
+ username: "kumaccino",
+ avatar: "a8829abe66866d7797b36f0bfac01086",
+ discriminator: "0",
+ public_flags: 128,
+ flags: 128,
+ banner: null,
+ accent_color: null,
+ global_name: "kumaccino",
+ avatar_decoration_data: null,
+ banner_color: null,
+ clan: null
+ },
+ pinned: false,
+ mention_everyone: false,
+ tts: false,
+ message_reference: {
+ type: 1,
+ channel_id: "497161350934560778",
+ message_id: "0"
+ },
+ position: 0,
+ message_snapshots: [
+ {
+ message: {
+ type: 0,
+ content: "What's cooking, good looking?",
+ mentions: [],
+ mention_roles: [],
+ attachments: [],
+ embeds: [],
+ timestamp: "2022-09-15T01:20:58.177000+00:00",
+ edited_timestamp: null,
+ flags: 0,
+ components: []
+ }
+ }
+ ]
+ },
+ forwarded_dont_scan_for_mentions: {
+ type: 0,
+ tts: false,
+ timestamp: "2025-02-08T09:07:45.547000+00:00",
+ position: 0,
+ pinned: false,
+ nonce: "1337711633497063424",
+ message_snapshots: [
+ {
+ message: {
+ type: 0,
+ timestamp: "2025-02-08T09:00:07.662000+00:00",
+ mentions: [],
+ flags: 0,
+ embeds: [],
+ edited_timestamp: null,
+ content: "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",
+ components: [],
+ attachments: []
+ }
+ }
+ ],
+ message_reference: {
+ type: 1,
+ message_id: "1337709539516223539",
+ guild_id: "500415824616620032",
+ channel_id: "794935364182867968"
+ },
+ mentions: [],
+ mention_roles: [],
+ mention_everyone: false,
+ member: {
+ roles: [
+ "1152297516755337248",
+ "300045569441660938",
+ "365531770420199435",
+ "1035943385338482698",
+ "1205645591212990515",
+ "1084555882341339259"
+ ],
+ premium_since: null,
+ pending: false,
+ nick: null,
+ mute: false,
+ joined_at: "2023-12-27T13:02:41.614000+00:00",
+ flags: 0,
+ deaf: false,
+ communication_disabled_until: null,
+ banner: null,
+ avatar: null
+ },
+ id: "1337711460024844350",
+ flags: 16384,
+ embeds: [],
+ edited_timestamp: null,
+ content: "",
+ components: [],
+ channel_type: 0,
+ channel_id: "286888431945252874",
+ author: {
+ username: "athenna2000",
+ public_flags: 0,
+ primary_guild: null,
+ id: "620341774984151063",
+ global_name: "Amelia 🍄",
+ discriminator: "0",
+ clan: null,
+ avatar_decoration_data: null,
+ avatar: "a30f5b1bf17b5a5f387f1bb49771a2f8"
+ },
+ 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: {
@@ -2473,6 +4194,7 @@ module.exports = {
},
webhook_id: "1109360903096369153"
},
+
reply_with_only_embed: {
type: 19,
tts: false,
@@ -3224,6 +4946,304 @@ module.exports = {
edited_timestamp: null,
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",
+ mentions: [],
+ mention_roles: [ "1182745800661540927" ],
+ attachments: [],
+ embeds: [
+ {
+ type: "gifv",
+ url: "https://tenor.com/view/get-real-gif-26176788",
+ provider: { name: "Tenor", url: "https://tenor.co" },
+ thumbnail: {
+ url: "https://media.tenor.com/Bz5pfRIu81oAAAAe/get-real.png",
+ proxy_url: "https://images-ext-1.discordapp.net/external/I71Ngw9drAKZhL_lhQRnAD_A-DkRNgN3EeZ2njv3Vi4/https/media.tenor.com/Bz5pfRIu81oAAAAe/get-real.png",
+ width: 632,
+ height: 640,
+ placeholder: "IBgSHwSYaIePiHh/d7h3d4eEJvkchZsA",
+ placeholder_version: 1,
+ flags: 0
+ },
+ video: {
+ url: "https://media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4",
+ proxy_url: "https://images-ext-1.discordapp.net/external/vNEtsZd1p_mWQh-nEIa0ZBndMEo2_oa1sAOMyXsgoWI/https/media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4",
+ width: 632,
+ height: 640,
+ placeholder: "IBgSHwSYaIePiHh/d7h3d4eEJvkchZsA",
+ placeholder_version: 1,
+ flags: 0
+ }
+ }
+ ],
+ timestamp: "2025-06-08T03:49:08.500000+00:00",
+ edited_timestamp: null,
+ flags: 0,
+ components: [],
+ id: "1381117821190279271",
+ channel_id: "1099031887500034088",
+ author: {
+ id: "771520384671416320",
+ username: "Bojack Horseman",
+ avatar: "d14f47194b6ebe4da2e18a56fc6dacfd",
+ discriminator: "9703",
+ public_flags: 0,
+ flags: 0,
+ bot: true,
+ banner: null,
+ accent_color: null,
+ global_name: null,
+ avatar_decoration_data: null,
+ collectibles: null,
+ banner_color: null,
+ clan: null,
+ primary_guild: null
+ },
+ pinned: false,
+ mention_everyone: false,
+ 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: {
@@ -3485,7 +5505,6 @@ module.exports = {
mention_roles: [],
mentions: [],
pinned: false,
- timestamp: "2023-08-16T22:38:38.641000+00:00",
tts: false,
type: 0
},
@@ -3559,7 +5578,6 @@ module.exports = {
mention_roles: [],
mentions: [],
pinned: false,
- timestamp: "2023-08-16T22:38:38.641000+00:00",
tts: false,
type: 0
},
@@ -3594,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: [],
@@ -3635,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: [],
@@ -3676,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: [],
@@ -3849,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: {
@@ -3915,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,
@@ -4128,7 +6217,54 @@ module.exports = {
guild_id: "112760669178241024"
},
position: 0
- }
+ },
+ ephemeral_message: {
+ webhook_id: "684280192553844747",
+ type: 20,
+ tts: false,
+ timestamp: "2024-09-29T11:22:04.865000+00:00",
+ position: 0,
+ pinned: false,
+ nonce: "1289910062243905536",
+ mentions: [],
+ mention_roles: [],
+ mention_everyone: false,
+ interaction_metadata: {
+ user: {baby: true},
+ type: 2,
+ name: "invite",
+ id: "1289910063691206717",
+ command_type: 1,
+ authorizing_integration_owners: {baby: true}
+ },
+ interaction: {
+ user: {baby: true},
+ type: 2,
+ name: "invite",
+ id: "1289910063691206717"
+ },
+ id: "1289910064995504182",
+ flags: 64,
+ embeds: [],
+ edited_timestamp: null,
+ content: "`@cadence:cadence.moe` is already in this server and this channel.",
+ components: [],
+ channel_id: "1100319550446252084",
+ author: {
+ username: "Matrix Bridge",
+ public_flags: 0,
+ id: "684280192553844747",
+ global_name: null,
+ discriminator: "5728",
+ clan: null,
+ bot: true,
+ avatar_decoration_data: null,
+ avatar: "48ae3c24f2a6ec5c60c41bdabd904018"
+ },
+ attachments: [],
+ application_id: "684280192553844747"
+ },
+ shard_id: 0
},
interaction_message: {
thinking_interaction_without_bot_user: {
@@ -4257,5 +6393,250 @@ module.exports = {
application_id: "1109360903096369153",
guild_id: "497159726455455754"
}
+ },
+ invite: {
+ irl: {
+ type: 0,
+ code: 'placeholder',
+ inviter: {
+ id: '772659086046658620',
+ username: 'cadence.worm',
+ avatar: '466df0c98b1af1e1388f595b4c1ad1b9',
+ discriminator: '0',
+ public_flags: 0,
+ flags: 0,
+ banner: null,
+ accent_color: 4534897,
+ global_name: 'cadence',
+ avatar_decoration_data: null,
+ collectibles: null,
+ banner_color: '#453271',
+ clan: null,
+ primary_guild: null
+ },
+ expires_at: '2025-06-15T08:39:43+00:00',
+ guild: {
+ id: '1338114140941586518',
+ name: 'self service',
+ splash: null,
+ banner: null,
+ description: null,
+ icon: null,
+ features: [],
+ verification_level: 0,
+ vanity_url_code: null,
+ nsfw_level: 0,
+ nsfw: false,
+ premium_subscription_count: 0,
+ premium_tier: 0
+ },
+ guild_id: '1338114140941586518',
+ channel: { id: '1338114141658939517', type: 0, name: 'general' },
+ guild_scheduled_event: {
+ id: '1381190945646710824',
+ guild_id: '1338114140941586518',
+ name: 'forest exploration',
+ description: '',
+ channel_id: null,
+ creator_id: '772659086046658620',
+ image: null,
+ scheduled_start_time: '2025-06-08T10:00:00.161000+00:00',
+ scheduled_end_time: '2025-06-08T12:00:00.161000+00:00',
+ status: 1,
+ entity_type: 3,
+ entity_id: null,
+ recurrence_rule: null,
+ user_count: 1,
+ privacy_level: 2,
+ sku_ids: [],
+ user_rsvp: null,
+ guild_scheduled_event_exceptions: [],
+ entity_metadata: { location: 'the dark forest' }
+ },
+ profile: {
+ id: '1338114140941586518',
+ name: 'self service',
+ icon_hash: null,
+ member_count: 2,
+ online_count: 1,
+ description: null,
+ banner_hash: null,
+ game_application_ids: [],
+ game_activity: {},
+ tag: null,
+ badge: 0,
+ badge_color_primary: '#ff0000',
+ badge_color_secondary: '#800000',
+ badge_hash: null,
+ traits: [],
+ features: [],
+ visibility: 2,
+ custom_banner_hash: null,
+ premium_subscription_count: 0,
+ premium_tier: 0
+ }
+ },
+ vc: {
+ type: 0,
+ code: 'placeholder',
+ inviter: {
+ id: '1024720274928697384',
+ username: '1024720274928697384',
+ avatar: '040a0652f1c76af3b71bb2c58ee0057b',
+ discriminator: '0',
+ public_flags: 0,
+ flags: 0,
+ banner: null,
+ accent_color: 4259841,
+ global_name: 'Regalia, Goddess of OH GOD OH FU',
+ avatar_decoration_data: null,
+ collectibles: null,
+ banner_color: '#410001',
+ clan: null,
+ primary_guild: null
+ },
+ expires_at: '2025-06-15T07:32:30+00:00',
+ guild: {
+ id: '1340545485542391879',
+ name: 'VRCooking',
+ splash: null,
+ banner: null,
+ description: null,
+ icon: '8e1948b83d79c11ccb32b9e54a5d85fd',
+ features: [ 'SOUNDBOARD', 'ACTIVITY_FEED_DISABLED_BY_USER' ],
+ verification_level: 0,
+ vanity_url_code: null,
+ nsfw_level: 0,
+ nsfw: false,
+ premium_subscription_count: 0,
+ premium_tier: 0
+ },
+ guild_id: '1340545485542391879',
+ channel: { id: '1368144987707019306', type: 2, name: 'Cooking' },
+ guild_scheduled_event: {
+ id: '1381174024801095751',
+ guild_id: '1340545485542391879',
+ name: 'Cooking (Netrunners)',
+ description: 'Short circuited brain interfaces actually just means your brain is medium rare, yum.',
+ channel_id: '1368144987707019306',
+ creator_id: '1024720274928697384',
+ image: null,
+ scheduled_start_time: '2025-06-09T03:00:00+00:00',
+ scheduled_end_time: null,
+ status: 1,
+ entity_type: 2,
+ entity_id: null,
+ recurrence_rule: null,
+ user_count: 2,
+ privacy_level: 2,
+ sku_ids: [],
+ user_rsvp: null,
+ guild_scheduled_event_exceptions: [],
+ entity_metadata: {}
+ },
+ profile: {
+ id: '1340545485542391879',
+ name: 'VRCooking',
+ icon_hash: '8e1948b83d79c11ccb32b9e54a5d85fd',
+ member_count: 18,
+ online_count: 13,
+ description: null,
+ banner_hash: null,
+ game_application_ids: [],
+ game_activity: {},
+ tag: null,
+ badge: 0,
+ badge_color_primary: '#ff0000',
+ badge_color_secondary: '#800000',
+ badge_hash: null,
+ traits: [],
+ features: [],
+ visibility: 2,
+ custom_banner_hash: null,
+ premium_subscription_count: 0,
+ premium_tier: 0
+ }
+ },
+ known_vc: {
+ type: 0,
+ code: 'placeholder',
+ inviter: {
+ id: '1024720274928697384',
+ username: '1024720274928697384',
+ avatar: '040a0652f1c76af3b71bb2c58ee0057b',
+ discriminator: '0',
+ public_flags: 0,
+ flags: 0,
+ banner: null,
+ accent_color: 4259841,
+ global_name: 'Regalia, Goddess of OH GOD OH FU',
+ avatar_decoration_data: null,
+ collectibles: null,
+ banner_color: '#410001',
+ clan: null,
+ primary_guild: null
+ },
+ expires_at: '2025-06-15T07:32:30+00:00',
+ guild: {
+ id: '112760669178241024',
+ name: 'Psychonauts 3',
+ splash: null,
+ banner: null,
+ description: null,
+ icon: '8e1948b83d79c11ccb32b9e54a5d85fd',
+ features: [ 'SOUNDBOARD', 'ACTIVITY_FEED_DISABLED_BY_USER' ],
+ verification_level: 0,
+ vanity_url_code: null,
+ nsfw_level: 0,
+ nsfw: false,
+ premium_subscription_count: 0,
+ premium_tier: 0
+ },
+ guild_id: '112760669178241024',
+ channel: { id: '1162005314908999790', type: 0, name: 'Hey.' },
+ guild_scheduled_event: {
+ id: '1381174024801095751',
+ guild_id: '112760669178241024',
+ name: 'Cooking (Netrunners)',
+ description: 'Short circuited brain interfaces actually just means your brain is medium rare, yum.',
+ channel_id: '1162005314908999790',
+ creator_id: '1024720274928697384',
+ image: null,
+ scheduled_start_time: '2025-06-09T03:00:00+00:00',
+ scheduled_end_time: null,
+ status: 1,
+ entity_type: 2,
+ entity_id: null,
+ recurrence_rule: null,
+ user_count: 2,
+ privacy_level: 2,
+ sku_ids: [],
+ user_rsvp: null,
+ guild_scheduled_event_exceptions: [],
+ entity_metadata: {}
+ },
+ profile: {
+ id: '112760669178241024',
+ name: 'Psychonauts 3',
+ icon_hash: '8e1948b83d79c11ccb32b9e54a5d85fd',
+ member_count: 18,
+ online_count: 13,
+ description: null,
+ banner_hash: null,
+ game_application_ids: [],
+ game_activity: {},
+ tag: null,
+ badge: 0,
+ badge_color_primary: '#ff0000',
+ badge_color_secondary: '#800000',
+ badge_hash: null,
+ traits: [],
+ features: [],
+ visibility: 2,
+ custom_banner_hash: null,
+ premium_subscription_count: 0,
+ premium_tier: 0
+ }
+ }
}
}
diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql
index 2c235613..1dd9dfed 100644
--- a/test/ooye-test-data.sql
+++ b/test/ooye-test-data.sql
@@ -1,40 +1,54 @@
BEGIN TRANSACTION;
+INSERT INTO guild_active (guild_id, autocreate) VALUES
+('112760669178241024', 1),
+('66192955777486848', 1),
+('665289423482519565', 0),
+('1345641201902288987', 1);
+
INSERT INTO guild_space (guild_id, space_id, privacy_level) VALUES
-('112760669178241024', '!jjWAGMeQdNrVZSSfvz: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),
-('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);
+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 sim (user_id, sim_name, localpart, mxid) VALUES
-('0', 'bot', '_ooye_bot', '@_ooye_bot:cadence.moe'),
-('820865262526005258', 'crunch_god', '_ooye_crunch_god', '@_ooye_crunch_god:cadence.moe'),
-('771520384671416320', 'bojack_horseman', '_ooye_bojack_horseman', '@_ooye_bojack_horseman:cadence.moe'),
-('112890272819507200', '.wing.', '_ooye_.wing.', '@_ooye_.wing.:cadence.moe'),
-('114147806469554185', 'extremity', '_ooye_extremity', '@_ooye_extremity:cadence.moe'),
-('111604486476181504', 'kyuugryphon', '_ooye_kyuugryphon', '@_ooye_kyuugryphon:cadence.moe'),
-('1109360903096369153', 'amanda', '_ooye_amanda', '@_ooye_amanda:cadence.moe'),
-('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_ooye__pk_zoego', '@_ooye__pk_zoego:cadence.moe'),
-('320067006521147393', 'papiophidian', '_ooye_papiophidian', '@_ooye_papiophidian:cadence.moe'),
-('772659086046658620', 'cadence', '_ooye_cadence', '@_ooye_cadence:cadence.moe');
+INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) SELECT channel_id, room_id, 0 FROM channel_room;
-INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES
-('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺');
+INSERT INTO sim (user_id, username, sim_name, mxid) VALUES
+('0', 'Matrix Bridge', 'bot', '@_ooye_bot:cadence.moe'),
+('820865262526005258', 'Crunch God', 'crunch_god', '@_ooye_crunch_god:cadence.moe'),
+('771520384671416320', 'Bojack Horseman', 'bojack_horseman', '@_ooye_bojack_horseman:cadence.moe'),
+('112890272819507200', 'wing', '.wing.', '@_ooye_.wing.:cadence.moe'),
+('114147806469554185', 'extremity', 'extremity', '@_ooye_extremity:cadence.moe'),
+('111604486476181504', 'kyuugryphon', 'kyuugryphon', '@_ooye_kyuugryphon:cadence.moe'),
+('1109360903096369153', 'Amanda', 'amanda', '@_ooye_amanda:cadence.moe'),
+('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '_pk_zoego', '_pk_zoego', '@_ooye__pk_zoego:cadence.moe'),
+('320067006521147393', 'papiophidian', 'papiophidian', '@_ooye_papiophidian:cadence.moe'),
+('772659086046658620', 'cadence.worm', 'cadence', '@_ooye_cadence:cadence.moe');
INSERT INTO sim_member (mxid, room_id, hashed_profile_content) VALUES
('@_ooye_bojack_horseman:cadence.moe', '!hYnGGlPHlbujVVfktC:cadence.moe', NULL),
('@_ooye_cadence:cadence.moe', '!BnKuBPCvyfOkhcUjEu:cadence.moe', NULL);
-INSERT INTO message_channel (message_id, channel_id) VALUES
+INSERT INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES
+('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺');
+
+INSERT INTO message_room (message_id, historical_room_index)
+WITH a (message_id, channel_id) AS (VALUES
('1106366167788044450', '122155380120748034'),
('1106366167788044451', '122155380120748034'),
('1106366167788044452', '122155380120748034'),
@@ -61,7 +75,15 @@ INSERT INTO message_channel (message_id, channel_id) VALUES
('1273204543739396116', '687028734322147344'),
('1273743950028607530', '1100319550446252084'),
('1278002262400176128', '1100319550446252084'),
-('1278001833876525057', '1100319550446252084');
+('1278001833876525057', '1100319550446252084'),
+('1191567971970191490', '176333891320283136'),
+('1144874214311067708', '687028734322147344'),
+('1339000288144658482', '176333891320283136'),
+('1381212840957972480', '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),
@@ -76,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),
@@ -100,7 +122,15 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part
('$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU', 'm.room.message', 'm.text', '1273204543739396116', 0, 0, 0),
('$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4', 'm.room.message', 'm.text', '1273743950028607530', 0, 0, 0),
('$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF', 'm.room.message', 'm.text', '1278002262400176128', 0, 0, 1),
-('$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM', 'm.room.message', 'm.text', '1278001833876525057', 0, 0, 1);
+('$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM', 'm.room.message', 'm.text', '1278001833876525057', 0, 0, 1),
+('$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE', 'm.room.message', 'm.text', '1191567971970191490', 0, 0, 1),
+('$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),
+('$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'),
@@ -115,12 +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/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/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'),
@@ -130,36 +163,56 @@ 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),
('!kLRqKKUQXcibIMtOpl:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0),
-('!BpMdOUkWWhFxmTrENV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'malformed mxc', 0),
+('!kLRqKKUQXcibIMtOpl:cadence.moe', '@test_auto_invite:example.org', NULL, NULL, 0),
('!fGgIymcYWOqjbSRUdV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0),
('!fGgIymcYWOqjbSRUdV:cadence.moe', '@rnl:cadence.moe', 'RNL', NULL, 0),
('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0),
-('!maggESguZBqGBZtSnr:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0),
+('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@ami:the-apothecary.club', 'Ami (she/her)', NULL, 0),
('!CzvdIdUQXgUjDVKxeU:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0),
-('!cBxtVRxDlZvSVhJXVK:cadence.moe', '@Milan:tchncs.de', 'Milan', NULL, 0),
+('!TqlyQmifxGUggEmdBN:cadence.moe', '@Milan:tchncs.de', 'Milan', NULL, 0),
('!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),
-('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@ami:the-apothecary.club', 'Ami (she/her)', NULL, 0),
-('!kLRqKKUQXcibIMtOpl:cadence.moe', '@test_auto_invite:example.org', NULL, NULL, 0),
-('!BpMdOUkWWhFxmTrENV:cadence.moe', '@test_auto_invite:example.org', NULL, NULL, 100);
+('!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');
-INSERT INTO "auto_emoji" ("name","emoji_id","guild_id") VALUES
-('L1','1144820033948762203','529176156398682115'),
-('L2','1144820084079087647','529176156398682115'),
-('_','_','529176156398682115');
+INSERT INTO auto_emoji (name, emoji_id) VALUES
+('L1', '1144820033948762203'),
+('L2', '1144820084079087647');
+
+INSERT INTO media_proxy (permitted_hash) VALUES
+(-429802515645771439),
+(4558604729745184757);
+
+INSERT INTO invite (mxid, room_id, type, name, avatar, topic) VALUES
+('@cadence:cadence.moe', '!zTMspHVUBhFLLSdmnS:cadence.moe', 'm.space', 'Data Horde', 'mxc://cadence.moe/TLqQOsTSrZkVKwBSWYTZNTrw', 'here is the space topic'),
+('@cadence:cadence.moe', '!jjmvBegULiLucuWEHU:cadence.moe', 'm.space', 'Epicord', NULL, NULL),
+('@cadence:cadence.moe', '!room:cadence.moe', NULL, 'some room', NULL, NULL),
+('@rnl:cadence.moe', '!space:cadence.moe', NULL, 'somebody else''s space', NULL, NULL);
+
+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 281df29d..e05b687d 100644
--- a/test/test.js
+++ b/test/test.js
@@ -2,46 +2,65 @@
const fs = require("fs")
const {join} = require("path")
-const stp = require("stream").promises
const sqlite = require("better-sqlite3")
+const {Writable} = require("stream")
const migrate = require("../src/db/migrate")
const HeatSync = require("heatsync")
-const {test} = require("supertape")
+const {test, extend} = require("supertape")
const data = require("./data")
-/** @type {import("node-fetch").default} */
-// @ts-ignore
-const fetch = require("node-fetch")
const {green} = require("ansi-colors")
const passthrough = require("../src/passthrough")
const db = new sqlite(":memory:")
const {reg} = require("../src/matrix/read-registration")
+reg.ooye.discord_token = "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby"
reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded
reg.ooye.server_name = "cadence.moe"
-reg.id = "baby" // don't actually take authenticated actions on the server
-reg.as_token = "baby"
-reg.hs_token = "baby"
+reg.ooye.namespace_prefix = "_ooye_"
+reg.sender_localpart = "_ooye_bot"
+reg.id = "baby"
+reg.as_token = "don't actually take authenticated actions on the server"
+reg.hs_token = "don't actually take authenticated actions on the server"
+reg.namespaces = {
+ users: [{regex: "@_ooye_.*:cadence.moe", exclusive: true}],
+ aliases: [{regex: "#_ooye_.*:cadence.moe", exclusive: true}]
+}
reg.ooye.bridge_origin = "https://bridge.example.org"
-reg.ooye.invite = []
+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})
const discord = {
+ // @ts-ignore - ignore guilds, because my data dump is missing random properties
guilds: new Map([
- [data.guild.general.id, data.guild.general]
+ [data.guild.general.id, data.guild.general],
+ [data.guild.fna.id, data.guild.fna],
+ [data.guild.data_horde.id, data.guild.data_horde]
+ ]),
+ guildChannelMap: new Map([
+ [data.guild.general.id, [data.channel.general.id, data.channel.updates.id]],
+ [data.guild.fna.id, []],
+ [data.guild.data_horde.id, [data.channel.saving_the_world.id]]
]),
application: {
id: "684280192553844747"
},
+ // @ts-ignore - ignore channels, because my data dump is missing random properties
channels: new Map([
+ [data.channel.general.id, data.channel.general],
+ [data.channel.updates.id, data.channel.updates],
["497161350934560778", {
guild_id: "497159726455455754"
}],
["498323546729086986", {
guild_id: "497159726455455754",
name: "bad-boots-prison"
- }]
+ }],
+ [data.channel.saving_the_world.id, data.channel.saving_the_world]
])
}
@@ -56,47 +75,46 @@ 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)
- await stp.pipeline(res.body, 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 end */
+ })
+ }
+ 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)
test("migrate: migration works", async t => {
@@ -112,28 +130,50 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
db.exec(fs.readFileSync(join(__dirname, "ooye-test-data.sql"), "utf8"))
+ require("./addbot.test")
require("../src/db/orm.test")
+ require("../src/web/server.test")
require("../src/discord/utils.test")
require("../src/matrix/kstate.test")
require("../src/matrix/api.test")
require("../src/matrix/file.test")
- require("../src/matrix/power.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")
require("../src/d2m/converters/user-to-mxid.test")
+ require("../src/m2d/event-dispatcher.test")
+ require("../src/m2d/converters/diff-pins.test")
require("../src/m2d/converters/event-to-message.test")
- require("../src/m2d/converters/utils.test")
+ require("../src/m2d/converters/emoji.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
new file mode 100644
index 00000000..250694aa
--- /dev/null
+++ b/test/web.js
@@ -0,0 +1,115 @@
+const passthrough = require("../src/passthrough")
+const h3 = require("h3")
+const http = require("http")
+const {SnowTransfer} = require("snowtransfer")
+const assert = require("assert").strict
+const domino = require("domino")
+const {extend} = require("supertape")
+const {reg} = require("../src/matrix/read-registration")
+
+const {AppService} = require("@cloudrac3r/in-your-element")
+const defaultAs = new AppService(reg)
+
+/**
+ * @param {string} html
+ */
+function getContent(html) {
+ const doc = domino.createDocument(html)
+ doc.querySelectorAll("svg").cache.forEach(e => e.remove())
+ const content = doc.getElementById("content")
+ assert(content)
+ return content.innerHTML.trim()
+}
+
+const test = extend({
+ has: operator => /** @param {string | RegExp} expected */ (html, expected, message = "should have substring in html content") => {
+ const content = getContent(html)
+ const is = expected instanceof RegExp ? content.match(expected) : content.includes(expected)
+ const {output, result} = operator.equal(content, expected.toString())
+ return {
+ expected: expected.toString(),
+ message,
+ is,
+ result: result,
+ output: output
+ }
+ }
+})
+
+class Router {
+ constructor() {
+ /** @type {Map} */
+ this.routes = new Map()
+ for (const method of ["get", "post", "put", "patch", "delete"]) {
+ this[method] = function(url, handler) {
+ const key = `${method} ${url}`
+ this.routes.set(key, handler)
+ }
+ }
+ }
+
+ /**
+ * @param {string} method
+ * @param {string} inputUrl
+ * @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")
+ const key = `${method} ${options.route || url.pathname}`
+ /* c8 ignore next */
+ if (!this.routes.has(key)) throw new Error(`Route not found: "${key}"`)
+
+ const req = {
+ method: method.toUpperCase(),
+ headers: options.headers || {},
+ url
+ }
+ const event = options.event || {}
+
+ if (typeof options.body === "object" && options.body.constructor === Object) {
+ options.body = JSON.stringify(options.body)
+ req.headers["content-type"] = "application/json"
+ }
+
+ try {
+ return await this.routes.get(key)(Object.assign(event, {
+ __is_event__: true,
+ method: method.toUpperCase(),
+ path: `${url.pathname}${url.search}`,
+ _requestBody: options.body,
+ node: {
+ req,
+ res: new http.ServerResponse(req)
+ },
+ 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",
+ createdAt: 0,
+ data: options.sessionData || {}
+ }
+ }
+ }
+ }))
+ } catch (error) {
+ // Post-process error data
+ defaultAs.app.options.onError(error)
+ throw error
+ }
+ }
+}
+
+const router = new Router()
+
+passthrough.as = {router, on() {}, options: defaultAs.app.options}
+
+module.exports.router = router
+module.exports.test = test