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]
In reply to an unbridged message from PokemonGod:' - + '
https://twitter.com/dynastic/status/1707484191963648161
⏺️ dynastic (@dynastic)' + formatted_body: '
' }]) @@ -321,25 +318,6 @@ test("message2event embeds: youtube video", async t => { }]) }) -test("message2event embeds: tenor gif should show a video link without a provider", async t => { - const events = await messageToEvent(data.message_with_embeds.tenor_gif, data.guild.general, {}, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "@Realdditors: get real https://tenor.com/view/get-real-gif-26176788", - format: "org.matrix.custom.html", - formatted_body: "@Realdditors get real https://tenor.com/view/get-real-gif-26176788", - "m.mentions": {} - }, { - $type: "m.room.message", - msgtype: "m.notice", - body: "| 🎞️ https://media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4", - format: "org.matrix.custom.html", - 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
5581", - "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: { @@ -373,16 +351,3 @@ 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🎞️ https://media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4
Jutomi I'm gonna make these sounds in your walls tonight`, - "m.mentions": {} - }]) -}) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 1d6288a..f2720bd 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -33,10 +33,8 @@ function getDiscordParseCallbacks(message, guild, useHTML) { user: node => { const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get() const interaction = message.interaction_metadata || message.interaction - const username = message.mentions?.find(ment => ment.id === node.id)?.username - || message.referenced_message?.mentions?.find(ment => ment.id === node.id)?.username + const username = message.mentions.find(ment => ment.id === node.id)?.username || (interaction?.user.id === node.id ? interaction.user.username : null) - || (message.author.id === node.id ? message.author.username : null) || node.id if (mxid && useHTML) { return `@${username}` @@ -201,12 +199,11 @@ async function attachmentToEvent(mentions, attachment) { /** * @param {DiscordTypes.APIMessage} message * @param {DiscordTypes.APIGuild} guild - * @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean, alwaysReturnFormattedBody?: boolean, scanTextForMentions?: boolean}} options default values: + * @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean, alwaysReturnFormattedBody?: boolean}} options default values: * - includeReplyFallback: true * - includeEditFallbackStar: false * - 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 + * @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API */ async function messageToEvent(message, guild, options = {}, di) { const events = [] @@ -264,9 +261,7 @@ 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}?} */ let repliedToEventRow = null - let repliedToUnknownEvent = false let repliedToEventSenderMxid = null if (message.mention_everyone) mentions.room = true @@ -282,8 +277,6 @@ async function messageToEvent(message, guild, options = {}, di) { 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 - } 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 @@ -403,7 +396,7 @@ async function messageToEvent(message, guild, options = {}, di) { const id = match[3] const name = match[2] const animated = !!match[1] - return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed + return emojiToKey.emojiToKey({id, name, animated}) // Register the custom emoji if needed })) async function transformParsedVia(parsed) { @@ -414,10 +407,8 @@ async function messageToEvent(message, guild, options = {}, di) { node.via = await getViaServersMemo(node.row.room_id) } } - ;for (const maybeChildNodesArray of [node, node.content, node.items]) { - if (Array.isArray(maybeChildNodesArray)) { - await transformParsedVia(maybeChildNodesArray) - } + if (Array.isArray(node.content)) { + await transformParsedVia(node.content) } } return parsed @@ -459,7 +450,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Fallback body/formatted_body for replies // This branch is optional - do NOT change anything apart from the reply fallback, since it may not be run - if ((repliedToEventRow || repliedToUnknownEvent) && options.includeReplyFallback !== false) { + if (repliedToEventRow && options.includeReplyFallback !== false) { let repliedToDisplayName let repliedToUserHtml if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) { @@ -481,26 +472,20 @@ async function messageToEvent(message, guild, options = {}, di) { } if (repliedToContent == "") repliedToContent = "[Media]" else if (!repliedToContent) repliedToContent = "[Replied-to message content wasn't provided by Discord]" - const {body: repliedToBody, html: repliedToHtml} = await transformContent(repliedToContent) - if (repliedToEventRow) { - // Generate a reply pointing to the Matrix event we found - html = `` - + html - body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions - + repliedToBody).split("\n").map(line => "> " + line).join("\n") - + "\n\n" + body - } else { // repliedToUnknownEvent - // This reply can't point to the Matrix event because it isn't bridged, we need to indicate this. - assert(message.referenced_message) - const dateDisplay = dUtils.howOldUnbridgedMessage(message.referenced_message.timestamp, message.timestamp) - html = ` In reply to ${repliedToUserHtml}` - + `
${repliedToHtml}In reply to ${dateDisplay} from ${repliedToDisplayName}:` - + `` - + html - body = (`In reply to ${dateDisplay}:\n${repliedToDisplayName}: ` - + repliedToBody).split("\n").map(line => "> " + line).join("\n") - + "\n\n" + body - } + 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}` + + html + body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions + + repliedToBody).split("\n").map(line => "> " + line).join("\n") + + "\n\n" + body } const newTextMessageEvent = { @@ -559,7 +544,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Forwarded content // @ts-ignore - const forwardedEvents = await messageToEvent(message.message_snapshots[0].message, guild, {includeReplyFallback: false, includeEditFallbackStar: false, alwaysReturnFormattedBody: true, scanTextForMentions: false}, di) + const forwardedEvents = await messageToEvent(message.message_snapshots[0].message, guild, {includeReplyFallback: false, includeEditFallbackStar: false, alwaysReturnFormattedBody: true}, di) // Indent for (const event of forwardedEvents) { @@ -585,7 +570,7 @@ async function messageToEvent(message, guild, options = {}, di) { if (message.content) { // Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention. const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)] - if (options.scanTextForMentions !== false && matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) { + if (matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) { const writtenMentionsText = matches.map(m => m[1].toLowerCase()) const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() assert(roomID) @@ -604,49 +589,6 @@ async function messageToEvent(message, guild, options = {}, di) { await addTextEvent(body, html, msgtype) } - // Then scheduled events - if (message.content && di?.snow) { - for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old - const invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) - const event = invite.guild_scheduled_event - if (!event) continue // the event ID provided was not valid - - const formatter = new Intl.DateTimeFormat("en-NZ", {month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "shortGeneric"}) // 9 June at 3:00 pm NZT - const rep = new mxUtils.MatrixStringBuilder() - - // Add time - if (event.scheduled_end_time) { - // @ts-ignore - no definition available for formatRange - rep.addParagraph(`Scheduled Event - ${formatter.formatRange(new Date(event.scheduled_start_time), new Date(event.scheduled_end_time))}`) - } else { - rep.addParagraph(`Scheduled Event - ${formatter.format(new Date(event.scheduled_start_time))}`) - } - - // Add details - rep.addLine(`## ${event.name}`, tag`${event.name}`) - if (event.description) rep.addLine(event.description) - - // Add location - if (event.entity_metadata?.location) { - rep.addParagraph(`📍 ${event.entity_metadata.location}`) - } else if (invite.channel?.name) { - const roomID = select("channel_room", "room_id", {channel_id: invite.channel.id}).pluck().get() - if (roomID) { - const via = await getViaServersMemo(roomID) - rep.addParagraph(`🔊 ${invite.channel.name} - https://matrix.to/#/${roomID}?${via}`, tag`🔊 ${invite.channel.name} - ${invite.channel.name}`) - } else { - rep.addParagraph(`🔊 ${invite.channel.name}`) - } - } - - // Send like an embed - let {body, formatted_body: html} = rep.get() - body = body.split("\n").map(l => "| " + l).join("\n") - html = ` In reply to ${repliedToUserHtml}` + + `
${repliedToHtml}${html}` - await addTextEvent(body, html, "m.notice") - } - } - // Then attachments if (message.attachments) { const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions))) @@ -654,12 +596,7 @@ async function messageToEvent(message, guild, options = {}, di) { } // 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. } @@ -672,7 +609,7 @@ async function messageToEvent(message, guild, options = {}, di) { const rep = new mxUtils.MatrixStringBuilder() // Provider - if (embed.provider?.name && embed.provider.name !== "Tenor") { + if (embed.provider?.name) { if (embed.provider.url) { rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`${embed.provider.name}`) } else { diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index fc933e3..6f77744 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -532,43 +532,6 @@ test("message2event: simple reply to matrix user, reply fallbacks disabled", asy }]) }) -test("message2event: reply to matrix user with mention", async t => { - const events = await messageToEvent(data.message.reply_to_matrix_user_mention, data.guild.general, {}, { - api: { - getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk", { - type: "m.room.message", - content: { - msgtype: "m.text", - body: "@_ooye_extremity:cadence.moe you owe me $30", - format: "org.matrix.custom.html", - formatted_body: "@_ooye_extremity:cadence.moe you owe me $30" - }, - sender: "@cadence:cadence.moe" - }) - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.relates_to": { - "m.in_reply_to": { - event_id: "$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk" - } - }, - "m.mentions": { - user_ids: [ - "@cadence:cadence.moe" - ] - }, - msgtype: "m.text", - body: "> okay 🤍 yay 🤍: @extremity: you owe me $30\n\nkys", - format: "org.matrix.custom.html", - formatted_body: - '' - + 'kys' - }]) -}) - test("message2event: reply with a video", async t => { const events = await messageToEvent(data.message.reply_with_video, data.guild.general, { api: { @@ -761,20 +724,6 @@ test("message2event: infinidoge's reply to ami's matrix smalltext singleline rep }]) }) -test("message2event: reply to a Discord message that wasn't bridged", async t => { - const events = await messageToEvent(data.message.reply_to_unknown_message, data.guild.general) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: `> In reply to a 1-day-old unbridged message:` - + `\n> Occimyy: BILLY BOB THE GREAT` - + `\n\nenigmatic`, - format: "org.matrix.custom.html", - formatted_body: ` In reply to okay 🤍 yay 🤍' - + '
@extremity you owe me $30In reply to a 1-day-old unbridged message from Occimyy:enigmatic`, - "m.mentions": {} - }]) -}) - test("message2event: simple written @mention for matrix user", async t => { const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, { api: { @@ -1185,191 +1134,3 @@ test("message2event: constructed forwarded text", async t => { } ]) }) - - -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` - + `
BILLY BOB THE GREATIf some folks have spare bandwidth then helping out ArchiveTeam with archiving soon to be deleted research and government data might be worthwhile https://social.luca.run/@luca/113950834185678114`, - "m.mentions": {}, - msgtype: "m.notice" - } - ]) -}) - -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: { - 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: { - 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.
This list will probably change in the future", - "m.mentions": {}, - msgtype: "m.text" - } - ]) - t.equal(called, 1) -}) diff --git a/src/d2m/converters/pins-to-list.js b/src/d2m/converters/pins-to-list.js index 3e890ea..047bb9f 100644 --- a/src/d2m/converters/pins-to-list.js +++ b/src/d2m/converters/pins-to-list.js @@ -4,28 +4,16 @@ const {select} = require("../../passthrough") /** * @param {import("discord-api-types/v10").RESTGetAPIChannelPinsResult} pins - * @param {{"m.room.pinned_events/"?: {pinned?: string[]}}} kstate */ -function pinsToList(pins, kstate) { - let alreadyPinned = kstate["m.room.pinned_events/"]?.pinned || [] - - // If any of the already pinned messages are bridged messages then remove them from the already pinned list. - // * If a bridged message is still pinned then it'll be added back in the next step. - // * If a bridged message was unpinned from Discord-side then it'll be unpinned from our side due to this step. - // * Matrix-only unbridged messages that are pinned will remain pinned. - alreadyPinned = alreadyPinned.filter(event_id => { - const messageID = select("event_message", "message_id", {event_id}).pluck().get() - return !messageID || pins.find(m => m.id === messageID) // if it is bridged then remove it from the filter - }) - +function pinsToList(pins) { /** @type {string[]} */ const result = [] for (const message of pins) { const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get() - if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID) + if (eventID) result.push(eventID) } result.reverse() - return alreadyPinned.concat(result) + return result } module.exports.pinsToList = pinsToList diff --git a/src/d2m/converters/pins-to-list.test.js b/src/d2m/converters/pins-to-list.test.js index d0657cb..7ee89b6 100644 --- a/src/d2m/converters/pins-to-list.test.js +++ b/src/d2m/converters/pins-to-list.test.js @@ -3,59 +3,10 @@ const data = require("../../../test/data") const {pinsToList} = require("./pins-to-list") test("pins2list: converts known IDs, ignores unknown IDs", t => { - const result = pinsToList(data.pins.faked, {}) + const result = pinsToList(data.pins.faked) t.deepEqual(result, [ "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" ]) }) - -test("pins2list: already pinned duplicate items are not moved", t => { - const result = pinsToList(data.pins.faked, { - "m.room.pinned_events/": { - pinned: [ - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA" - ] - } - }) - t.deepEqual(result, [ - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", - "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" - ]) -}) - -test("pins2list: already pinned unknown items are not moved", t => { - const result = pinsToList(data.pins.faked, { - "m.room.pinned_events/": { - pinned: [ - "$unknown1", - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$unknown2" - ] - } - }) - t.deepEqual(result, [ - "$unknown1", - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$unknown2", - "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", - "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" - ]) -}) - -test("pins2list: bridged messages can be unpinned", t => { - const result = pinsToList(data.pins.faked.slice(0, -2), { - "m.room.pinned_events/": { - pinned: [ - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4" - ] - } - }) - t.deepEqual(result, [ - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", - ]) -}) diff --git a/src/d2m/converters/user-to-mxid.js b/src/d2m/converters/user-to-mxid.js index e0ab137..a619b36 100644 --- a/src/d2m/converters/user-to-mxid.js +++ b/src/d2m/converters/user-to-mxid.js @@ -64,7 +64,7 @@ function userToSimName(user) { } // 1. Is sim user already registered? - const existing = select("sim", "user_id", {user_id: user.id}).pluck().get() + const existing = select("sim", "sim_name", {user_id: user.id}).pluck().get() assert.equal(existing, null, "Shouldn't try to create a new name for an existing sim") // 2. Register based on username (could be new or old format) diff --git a/src/d2m/discord-client.js b/src/d2m/discord-client.js index b05d48f..bffb904 100644 --- a/src/d2m/discord-client.js +++ b/src/d2m/discord-client.js @@ -1,15 +1,10 @@ // @ts-check -const DiscordTypes = require("discord-api-types/v10") -const {Endpoints, SnowTransfer} = require("snowtransfer") -const {reg} = require("../matrix/read-registration") -const {Client: CloudStorm} = require("cloudstorm") - -// @ts-ignore -Endpoints.BASE_HOST = reg.ooye.discord_origin || "https://discord.com"; Endpoints.CDN_URL = reg.ooye.discord_cdn_origin || "https://cdn.discordapp.com" +const { SnowTransfer } = require("snowtransfer") +const { Client: CloudStorm } = require("cloudstorm") const passthrough = require("../passthrough") -const {sync} = passthrough +const { sync } = passthrough /** @type {import("./discord-packets")} */ const discordPackets = sync.require("./discord-packets") @@ -29,7 +24,7 @@ class DiscordClient { intents: [ "DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING", "GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", - "MESSAGE_CONTENT", "GUILD_PRESENCES" + "MESSAGE_CONTENT" ], ws: { compress: false, @@ -37,15 +32,15 @@ class DiscordClient { } }) this.ready = false - /** @type {DiscordTypes.APIUser} */ + /** @type {import("discord-api-types/v10").APIUser} */ // @ts-ignore avoid setting as or null because we know we need to wait for ready anyways this.user = null - /** @type {Pick
- Don't be a dick
- Follow rule number 1
- Follow Discord TOS
- Do not post NSFW content, shock content, suggestive content
- Please keep #wonderland professional and helpful, no random off-topic joking
} */ + /** @type {Pick } */ // @ts-ignore this.application = null - /** @type {Map } */ + /** @type {Map } */ this.channels = new Map() - /** @type {Map } */ // we get members from the GUILD_CREATE and we do maintain it + /** @type {Map } */ this.guilds = new Map() /** @type {Map >} */ this.guildChannelMap = new Map() @@ -62,6 +57,9 @@ class DiscordClient { addEventLogger("error", "Error") addEventLogger("disconnected", "Disconnected") addEventLogger("ready", "Ready") + this.snow.requestHandler.on("requestError", (requestID, error) => { + console.error("request error:", error) + }) } } diff --git a/src/d2m/discord-packets.js b/src/d2m/discord-packets.js index 017d50e..2e97671 100644 --- a/src/d2m/discord-packets.js +++ b/src/d2m/discord-packets.js @@ -32,7 +32,6 @@ const utils = { console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`) } else if (message.t === "GUILD_CREATE") { - message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web client.guilds.set(message.d.id, message.d) const arr = [] client.guildChannelMap.set(message.d.id, arr) @@ -102,13 +101,6 @@ const utils = { } } - } else if (message.t === "GUILD_MEMBER_UPDATE") { - const guild = client.guilds.get(message.d.guild_id) - const member = guild?.members.find(m => m.user.id === message.d.user.id) - if (member) { // only update existing members (i.e. the bot's own member) - don't want to inflate the cache with new irrelevant ones - Object.assign(member, message.d) - } - } else if (message.t === "THREAD_CREATE") { client.channels.set(message.d.id, message.d) if (message.d["guild_id"]) { @@ -157,17 +149,55 @@ const utils = { } // Event dispatcher for OOYE bridge operations - if (listen === "full" && message.t) { + if (listen === "full") { try { - if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") { + if (message.t === "GUILD_UPDATE") { + await eventDispatcher.onGuildUpdate(client, message.d) + + } else if (message.t === "GUILD_EMOJIS_UPDATE" || message.t === "GUILD_STICKERS_UPDATE") { + await eventDispatcher.onExpressionsUpdate(client, message.d) + + } else if (message.t === "CHANNEL_UPDATE") { + await eventDispatcher.onChannelOrThreadUpdate(client, message.d, false) + + } else if (message.t === "CHANNEL_PINS_UPDATE") { + await eventDispatcher.onChannelPinsUpdate(client, message.d) + + } else if (message.t === "CHANNEL_DELETE") { + await eventDispatcher.onChannelDelete(client, message.d) + + } else if (message.t === "THREAD_CREATE") { + // @ts-ignore + await eventDispatcher.onThreadCreate(client, message.d) + + } else if (message.t === "THREAD_UPDATE") { + await eventDispatcher.onChannelOrThreadUpdate(client, message.d, true) + + } else if (message.t === "MESSAGE_CREATE") { + await eventDispatcher.onMessageCreate(client, message.d) + + } else if (message.t === "MESSAGE_UPDATE") { + await eventDispatcher.onMessageUpdate(client, message.d) + + } else if (message.t === "MESSAGE_DELETE") { + await eventDispatcher.onMessageDelete(client, message.d) + + } else if (message.t === "MESSAGE_DELETE_BULK") { + await eventDispatcher.onMessageDeleteBulk(client, message.d) + + } else if (message.t === "TYPING_START") { + await eventDispatcher.onTypingStart(client, message.d) + + } else if (message.t === "MESSAGE_REACTION_ADD") { + await eventDispatcher.onReactionAdd(client, message.d) + + } else if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") { await eventDispatcher.onSomeReactionsRemoved(client, message.d) } else if (message.t === "INTERACTION_CREATE") { await interactions.dispatchInteraction(message.d) - - } else if (message.t in eventDispatcher) { - await eventDispatcher[message.t](client, message.d) } + } catch (e) { // Let OOYE try to handle errors too await eventDispatcher.onError(client, e, message) diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 1698317..a80c451 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -2,6 +2,7 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") +const util = require("util") const {sync, db, select, from} = require("../passthrough") /** @type {import("./actions/send-message")}) */ @@ -26,18 +27,19 @@ const updatePins = sync.require("./actions/update-pins") const api = sync.require("../matrix/api") /** @type {import("../discord/utils")} */ const dUtils = sync.require("../discord/utils") +/** @type {import("../m2d/converters/utils")} */ +const mxUtils = require("../m2d/converters/utils") /** @type {import("./actions/speedbump")} */ const speedbump = sync.require("./actions/speedbump") /** @type {import("./actions/retrigger")} */ const retrigger = sync.require("./actions/retrigger") -/** @type {import("./actions/set-presence")} */ -const setPresence = sync.require("./actions/set-presence") -/** @type {import("../m2d/event-dispatcher")} */ -const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") -const {Semaphore} = require("@chriscdn/promise-semaphore") +/** @type {any} */ // @ts-ignore bad types from semaphore +const Semaphore = require("@chriscdn/promise-semaphore") const checkMissedPinsSema = new Semaphore() +let lastReportedEvent = 0 + // Grab Discord events we care about for the bridge, check them, and pass them on module.exports = { @@ -47,14 +49,48 @@ module.exports = { * @param {import("cloudstorm").IGatewayMessage} gatewayMessage */ async onError(client, e, gatewayMessage) { + 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? definitely need to be able to store the formatted event body to load back in later + console.error(`while handling this ${gatewayMessage.t} gateway event:`) + console.dir(gatewayMessage.d, {depth: null}) + + if (gatewayMessage.t === "TYPING_START") return + + if (Date.now() - lastReportedEvent < 5000) return + lastReportedEvent = Date.now() + const channelID = gatewayMessage.d["channel_id"] if (!channelID) return const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() if (!roomID) return - if (gatewayMessage.t === "TYPING_START") return + let stackLines = null + if (e.stack) { + stackLines = e.stack.split("\n") + let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/")) + if (cloudstormLine !== -1) { + stackLines = stackLines.slice(0, cloudstormLine - 2) + } + } - await matrixEventDispatcher.sendError(roomID, "Discord", gatewayMessage.t, e, gatewayMessage) + const builder = new mxUtils.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) { + builder.addLine(`Error trace:\n${stackLines.join("\n")}`, ` `) + } + builder.addLine("", `Error trace
${stackLines.join("\n")}`) + await api.sendEvent(roomID, "m.room.message", { + ...builder.get(), + "moe.cadence.ooye.error": { + source: "discord", + payload: gatewayMessage + }, + "m.mentions": { + user_ids: ["@cadence:cadence.moe"] + } + }) }, /** @@ -69,10 +105,7 @@ module.exports = { const bridgedChannels = select("channel_room", "channel_id").pluck().all() const preparedExists = db.prepare("SELECT channel_id FROM message_channel WHERE channel_id = ? LIMIT 1") const preparedGet = select("event_message", "event_id", {}, "WHERE message_id = ?").pluck() - /** @type {(DiscordTypes.APIChannel & {type: DiscordTypes.GuildChannelType})[]} */ - let channels = [] - channels = channels.concat(guild.channels, guild.threads) - for (const channel of channels) { + for (const channel of guild.channels.concat(guild.threads)) { if (!bridgedChannels.includes(channel.id)) continue if (!("last_message_id" in channel) || !channel.last_message_id) continue @@ -108,24 +141,13 @@ module.exports = { }) // console.log(`[check missed messages] got ${messages.length} messages; last message that IS bridged is at position ${latestBridgedMessageIndex} in the channel`) if (latestBridgedMessageIndex === -1) latestBridgedMessageIndex = 1 // rather than crawling the ENTIRE channel history, let's just bridge the most recent 1 message to make it up to date. - - // We get member data so that we can accurately update any changes to nickname or permissions that have occurred in the meantime - // The rate limit is lax enough that the backlog will still be pretty quick (at time of writing, 5 per 1 second per guild) - /** @type {MapOriginal payload
${util.inspect(gatewayMessage.d, false, 4, false)}} id -> member: cache members for the run because people talk to each other */ - const members = new Map() - - // Send in order for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) { - const message = messages[i] - - if (!members.has(message.author.id)) members.set(message.author.id, await client.snow.guild.getGuildMember(guild.id, message.author.id).catch(() => undefined)) - await module.exports.MESSAGE_CREATE(client, { + const simulatedGatewayDispatchData = { guild_id: guild.id, - member: members.get(message.author.id), - // @ts-ignore backfill: true, - ...message - }) + ...messages[i] + } + await module.exports.onMessageCreate(client, simulatedGatewayDispatchData) } } }, @@ -172,7 +194,7 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.APIThreadChannel} thread */ - async THREAD_CREATE(client, thread) { + async onThreadCreate(client, thread) { const channelID = thread.parent_id || undefined const parentRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel (won't autocreate) @@ -184,7 +206,7 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayGuildUpdateDispatchData} guild */ - async GUILD_UPDATE(client, guild) { + async onGuildUpdate(client, guild) { const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get() if (!spaceID) return await createSpace.syncSpace(guild) @@ -193,26 +215,19 @@ module.exports = { /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread + * @param {boolean} isThread */ - async CHANNEL_UPDATE(client, channelOrThread) { + async onChannelOrThreadUpdate(client, channelOrThread, isThread) { const roomID = select("channel_room", "room_id", {channel_id: channelOrThread.id}).pluck().get() if (!roomID) return // No target room to update the data on await createRoom.syncRoom(channelOrThread.id) }, - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayChannelUpdateDispatchData} thread - */ - async THREAD_UPDATE(client, thread) { - await module.exports.CHANNEL_UPDATE(client, thread) - }, - /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayChannelPinsUpdateDispatchData} data */ - async CHANNEL_PINS_UPDATE(client, data) { + async onChannelPinsUpdate(client, data) { const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get() if (!roomID) return // No target room to update pins in const convertedTimestamp = updatePins.convertTimestamp(data.last_pin_timestamp) @@ -223,7 +238,7 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayChannelDeleteDispatchData} channel */ - async CHANNEL_DELETE(client, channel) { + async onChannelDelete(client, channel) { const guildID = channel["guild_id"] if (!guildID) return // channel must have been a DM channel or something const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() @@ -236,7 +251,7 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageCreateDispatchData} message */ - async MESSAGE_CREATE(client, message) { + async onMessageCreate(client, message) { if (message.author.username === "Deleted User") return // Nothing we can do for deleted users. const channel = client.channels.get(message.channel_id) if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages. @@ -266,7 +281,7 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageUpdateDispatchData} data */ - async MESSAGE_UPDATE(client, data) { + async onMessageUpdate(client, data) { // Based on looking at data they've sent me over the gateway, this is the best way to check for meaningful changes. // If the message content is a string then it includes all interesting fields and is meaningful. // Otherwise, if there are embeds, then the system generated URL preview embeds. @@ -284,7 +299,7 @@ module.exports = { if (affected) return // Check that the sending-to room exists, and deal with Eventual Consistency(TM) - if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_UPDATE, client, data)) return + if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageUpdate, client, data)) return /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ // @ts-ignore @@ -302,7 +317,7 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data */ - async MESSAGE_REACTION_ADD(client, data) { + async onReactionAdd(client, data) { if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix. await addReaction.addReaction(data) }, @@ -319,25 +334,25 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageDeleteDispatchData} data */ - async MESSAGE_DELETE(client, data) { + async onMessageDelete(client, data) { speedbump.onMessageDelete(data.id) - if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_DELETE, client, data)) return + if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageDelete, client, data)) return await deleteMessage.deleteMessage(data) }, - /** + /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageDeleteBulkDispatchData} data */ - async MESSAGE_DELETE_BULK(client, data) { - await deleteMessage.deleteMessageBulk(data) - }, + async onMessageDeleteBulk(client, data) { + await deleteMessage.deleteMessageBulk(data) + }, /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayTypingStartDispatchData} data */ - async TYPING_START(client, data) { + async onTypingStart(client, data) { const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get() if (!roomID) return const mxid = from("sim").join("sim_member", "mxid").where({user_id: data.user_id, room_id: roomID}).pluck("mxid").get() @@ -350,27 +365,9 @@ module.exports = { /** * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData} data + * @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData | DiscordTypes.GatewayGuildStickersUpdateDispatchData} data */ - async GUILD_EMOJIS_UPDATE(client, data) { + async onExpressionsUpdate(client, data) { await createSpace.syncSpaceExpressions(data, false) - }, - - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayGuildStickersUpdateDispatchData} data - */ - async GUILD_STICKERS_UPDATE(client, data) { - await createSpace.syncSpaceExpressions(data, false) - }, - - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayPresenceUpdateDispatchData} data - */ - PRESENCE_UPDATE(client, data) { - const status = data.status - if (!status) return - setPresence.presenceTracker.incomingPresence(data.user.id, data.guild_id, status) } } diff --git a/src/db/migrate.js b/src/db/migrate.js index 46d0c14..7c1faf9 100644 --- a/src/db/migrate.js +++ b/src/db/migrate.js @@ -6,8 +6,7 @@ const {join} = require("path") async function migrate(db) { let files = fs.readdirSync(join(__dirname, "migrations")) files = files.sort() - db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL, PRIMARY KEY (filename)) WITHOUT ROWID").run() - /** @type {string} */ + db.prepare("CREATE TABLE IF NOT EXISTS migration (filename TEXT NOT NULL)").run() let progress = db.prepare("SELECT * FROM migration").pluck().get() if (!progress) { progress = "" @@ -38,8 +37,6 @@ async function migrate(db) { if (migrationRan) { console.log("Database migrations all done.") } - - db.pragma("foreign_keys = on") } module.exports.migrate = migrate diff --git a/src/db/migrations/0016-foreign-keys.sql b/src/db/migrations/0016-foreign-keys.sql deleted file mode 100644 index 7a2b26c..0000000 --- a/src/db/migrations/0016-foreign-keys.sql +++ /dev/null @@ -1,147 +0,0 @@ --- /docs/foreign-keys.md - --- 2 -BEGIN TRANSACTION; - --- *** channel_room *** - --- 4 --- adding UNIQUE to room_id here will auto-generate the usable index we wanted -CREATE TABLE "new_channel_room" ( - "channel_id" TEXT NOT NULL, - "room_id" TEXT NOT NULL UNIQUE, - "name" TEXT NOT NULL, - "nick" TEXT, - "thread_parent" TEXT, - "custom_avatar" TEXT, - "last_bridged_pin_timestamp" INTEGER, - "speedbump_id" TEXT, - "speedbump_checked" INTEGER, - "speedbump_webhook_id" TEXT, - "guild_id" TEXT, - PRIMARY KEY("channel_id"), - FOREIGN KEY("guild_id") REFERENCES "guild_active"("guild_id") ON DELETE CASCADE -) WITHOUT ROWID; --- 5 -INSERT INTO new_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) SELECT channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id FROM channel_room; --- 6 -DROP TABLE channel_room; --- 7 -ALTER TABLE new_channel_room RENAME TO channel_room; - --- *** message_channel *** - --- 4 -CREATE TABLE "new_message_channel" ( - "message_id" TEXT NOT NULL, - "channel_id" TEXT NOT NULL, - PRIMARY KEY("message_id"), - FOREIGN KEY("channel_id") REFERENCES "channel_room"("channel_id") ON DELETE CASCADE -) WITHOUT ROWID; --- 5 --- don't copy any orphaned messages -INSERT INTO new_message_channel (message_id, channel_id) SELECT message_id, channel_id FROM message_channel WHERE channel_id IN (SELECT channel_id FROM channel_room); --- 6 -DROP TABLE message_channel; --- 7 -ALTER TABLE new_message_channel RENAME TO message_channel; - --- *** event_message *** - --- clean up any orphaned events -DELETE FROM event_message WHERE message_id NOT IN (SELECT message_id FROM message_channel); --- 4 -CREATE TABLE "new_event_message" ( - "event_id" TEXT NOT NULL, - "event_type" TEXT, - "event_subtype" TEXT, - "message_id" TEXT NOT NULL, - "part" INTEGER NOT NULL, - "reaction_part" INTEGER NOT NULL, - "source" INTEGER NOT NULL, - PRIMARY KEY("message_id","event_id"), - FOREIGN KEY("message_id") REFERENCES "message_channel"("message_id") ON DELETE CASCADE -) WITHOUT ROWID; --- 5 -INSERT INTO new_event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) SELECT event_id, event_type, event_subtype, message_id, part, reaction_part, source FROM event_message; --- 6 -DROP TABLE event_message; --- 7 -ALTER TABLE new_event_message RENAME TO event_message; - --- *** guild_space *** - --- 4 -CREATE TABLE "new_guild_space" ( - "guild_id" TEXT NOT NULL, - "space_id" TEXT NOT NULL, - "privacy_level" INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY("guild_id"), - FOREIGN KEY("guild_id") REFERENCES "guild_active"("guild_id") ON DELETE CASCADE -) WITHOUT ROWID; --- 5 -INSERT INTO new_guild_space (guild_id, space_id, privacy_level) SELECT guild_id, space_id, privacy_level FROM guild_space; --- 6 -DROP TABLE guild_space; --- 7 -ALTER TABLE new_guild_space RENAME TO guild_space; - --- *** reaction *** - --- 4 -CREATE TABLE "new_reaction" ( - "hashed_event_id" INTEGER NOT NULL, - "message_id" TEXT NOT NULL, - "encoded_emoji" TEXT NOT NULL, - PRIMARY KEY("hashed_event_id"), - FOREIGN KEY("message_id") REFERENCES "message_channel"("message_id") ON DELETE CASCADE -) WITHOUT ROWID; --- 5 -INSERT INTO new_reaction (hashed_event_id, message_id, encoded_emoji) SELECT hashed_event_id, message_id, encoded_emoji FROM reaction WHERE message_id IN (SELECT message_id FROM message_channel); --- 6 -DROP TABLE reaction; --- 7 -ALTER TABLE new_reaction RENAME TO reaction; - --- *** webhook *** - --- 4 --- using RESTRICT instead of CASCADE as a reminder that the webhooks also need to be deleted using the Discord API, it can't just be entirely automatic -CREATE TABLE "new_webhook" ( - "channel_id" TEXT NOT NULL, - "webhook_id" TEXT NOT NULL, - "webhook_token" TEXT NOT NULL, - PRIMARY KEY("channel_id"), - FOREIGN KEY("channel_id") REFERENCES "channel_room"("channel_id") ON DELETE RESTRICT -) WITHOUT ROWID; --- 5 -INSERT INTO new_webhook (channel_id, webhook_id, webhook_token) SELECT channel_id, webhook_id, webhook_token FROM webhook WHERE channel_id IN (SELECT channel_id FROM channel_room); --- 6 -DROP TABLE webhook; --- 7 -ALTER TABLE new_webhook RENAME TO webhook; - --- *** sim *** - --- 4 --- while we're at it, rebuild this table to give it WITHOUT ROWID, remove UNIQUE, and replace the localpart column with username. no foreign keys needed -CREATE TABLE "new_sim" ( - "user_id" TEXT NOT NULL, - "username" TEXT NOT NULL, - "sim_name" TEXT NOT NULL, - "mxid" TEXT NOT NULL, - PRIMARY KEY("user_id") -) WITHOUT ROWID; --- 5 -INSERT INTO new_sim (user_id, username, sim_name, mxid) SELECT user_id, sim_name, sim_name, mxid FROM sim; --- 6 -DROP TABLE sim; --- 7 -ALTER TABLE new_sim RENAME TO sim; - --- *** end *** - --- 10 -PRAGMA foreign_key_check; --- 11 -COMMIT; diff --git a/src/db/migrations/0017-analyze.sql b/src/db/migrations/0017-analyze.sql deleted file mode 100644 index 802fca2..0000000 --- a/src/db/migrations/0017-analyze.sql +++ /dev/null @@ -1,225 +0,0 @@ --- https://www.sqlite.org/lang_analyze.html - -BEGIN TRANSACTION; - -ANALYZE sqlite_schema; - -DELETE FROM "sqlite_stat1"; -INSERT INTO "sqlite_stat1" ("tbl","idx","stat") VALUES ('sim','sim','625 1'), - ('reaction','reaction','3242 1'), - ('channel_room','channel_room','389 1'), - ('channel_room','sqlite_autoindex_channel_room_1','389 1'), - ('media_proxy','media_proxy','5068 1'), - ('sim_proxy','sim_proxy','36 1'), - ('webhook','webhook','155 1'), - ('member_cache','member_cache','784 3 1'), - ('member_power','member_power','1 1 1'), - ('file','file','21862 1'), - ('message_channel','message_channel','366884 1'), - ('lottie','lottie','19 1'), - ('event_message','event_message','382920 1 1'), - ('migration',NULL,'1'), - ('sim_member','sim_member','2871 7 1'), - ('guild_space','guild_space','32 1'), - ('guild_active','guild_active','34 1'), - ('emoji','emoji','2563 1'), - ('auto_emoji','auto_emoji','3 1'); - -DELETE FROM "sqlite_stat4"; -INSERT INTO "sqlite_stat4" ("tbl","idx","neq","nlt","ndlt","sample") VALUES ('sim','sim','1','69','69',X'0231313137363631373038303932333039353039'), - ('sim','sim','1','139','139',X'0231313530383936363934333439373931323332'), - ('sim','sim','1','209','209',X'0231323231383737363334373737323139303732'), - ('sim','sim','1','279','279',X'0231333039313431353735353334313136383636'), - ('sim','sim','1','349','349',X'0231333935343433383235363034313635363434'), - ('sim','sim','1','419','419',X'0231353335363239373830383338353134373030'), - ('sim','sim','1','489','489',X'0231363930333339333730353930363636383034'), - ('sim','sim','1','559','559',X'0231383535353736303637393137323137383133'), - ('reaction','reaction','1','360','360',X'020699d5faceefb5fb4f'), - ('reaction','reaction','1','721','721',X'0206b61095e98b6b2fb1'), - ('reaction','reaction','1','1082','1082',X'0206d1dcb418603a5eaa'), - ('reaction','reaction','1','1443','1443',X'0206ef9fc42b9df746ad'), - ('reaction','reaction','1','1804','1804',X'02060f38c1f98f130605'), - ('reaction','reaction','1','2165','2165',X'02062b53df6dab7b1067'), - ('reaction','reaction','1','2526','2526',X'020645dd7e7f60c4aac7'), - ('reaction','reaction','1','2887','2887',X'0206658d2fe735805979'), - ('channel_room','channel_room','1','43','43',X'023331313434393131333330393139333231363330'), - ('channel_room','channel_room','1','87','87',X'023331313835343033343830303934303335393738'), - ('channel_room','channel_room','1','131','131',X'023331323139353036353836343139303638393839'), - ('channel_room','channel_room','1','175','175',X'023331323336353538333034323331303334393630'), - ('channel_room','channel_room','1','219','219',X'023331323933373932323135333930323234343235'), - ('channel_room','channel_room','1','263','263',X'023331333333323139363936393333323038303937'), - ('channel_room','channel_room','1','307','307',X'0231343835363635393733363433333738363938'), - ('channel_room','channel_room','1','351','351',X'0231373039303432313039353632323234363731'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','6 6','6 6',X'034b3321416a6c4c49464e6248646474424a6d4d73503a636164656e63652e6d6f6531313531333434383735363139343833373233'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','34 34','34 34',X'034b3321474b4a63424a6b527a47634e4855686c50613a636164656e63652e6d6f6531303237393433323532323237323630343637'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','43 43','43 43',X'034b3121484b50534d62736d694673506d6268414f513a636164656e63652e6d6f65313931343837343839393433343034353434'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','58 58','58 58',X'034b33214a4479425a685545706874784f6e6f6569513a636164656e63652e6d6f6531323937323836373434353633313236333532'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','87 87','87 87',X'034b33214e544d724e686e715271695755654d494d523a636164656e63652e6d6f6531323235323434353738393939373031353536'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','108 108','108 108',X'034b332151444e44796656674e7657565345656876713a636164656e63652e6d6f6531313432333134303935353535363435343830'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','131 131','131 131',X'034b3121544171536b575752654b43506f584c6a75483a636164656e63652e6d6f65383737303730363531343733363631393532'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','175 175','175 175',X'034b3321594249486864714e697255585941587845563a636164656e63652e6d6f6531323335303831373939353936373639333730'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','177 177','177 177',X'034b3321594b46454e79716667696951686956496b533a636164656e63652e6d6f6531323934363237303431343530333933373034'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','186 186','186 186',X'034b3321596f54644f55766a53765349767266716c653a636164656e63652e6d6f6531323734313936373733383435333430323933'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','202 202','202 202',X'034b3121625877616673695372655647676470535a463a636164656e63652e6d6f65373339303137363739373936343336393932'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','208 208','208 208',X'034b3321634a4b6843764943795377717a47634551423a636164656e63652e6d6f6531323732363632303331323238373331343834'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','219 219','219 219',X'034b3121656455786a56647a6755765844554951434b3a636164656e63652e6d6f65343937313631333530393334353630373738'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','242 242','242 242',X'034b31216a4d746e6e6f51414e4278466a486458494d3a636164656e63652e6d6f65373634353135323932303539323731313939'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','263 263','263 263',X'034b31216c7a776870666a5a6e59797468656a7453483a636164656e63652e6d6f65383838343831373132383438343030343534'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','264 264','264 264',X'034b33216d454c5846716a426958726d7558796943723a636164656e63652e6d6f6531313936393134373631303430393234373432'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','268 268','268 268',X'034b33216d557765577571546761574a767769576a653a636164656e63652e6d6f6531323936373131393236333032333830303834'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','291 291','291 291',X'034b3321704761494e45534643587a634e42497a724e3a636164656e63652e6d6f6531303237343531333333333533313532353232'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','306 306','306 306',X'034b3321717646656248564f4b6876454e54494563763a636164656e63652e6d6f6531323737373238383139323232303230313436'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','307 307','307 307',X'034b332171767370666d716f476449634a66794c506c3a636164656e63652e6d6f6531323936393138333638393539343633343735'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','351 351','351 351',X'034b3321774e7a7741724a47796f4c5168426f544e4b3a636164656e63652e6d6f6531303238303436373930333435333739383930'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','368 368','368 368',X'034b3321794e6d504c7765654a69756570725a677a733a636164656e63652e6d6f6531323531393631373233373731313632363234'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','376 376','376 376',X'034b3121796a4879795772466f704c66646878564e423a636164656e63652e6d6f65333336313537353037303734353233313336'), - ('channel_room','sqlite_autoindex_channel_room_1','1 1','379 379','379 379',X'034b31217a4c4f6b62766b44587551465948594555673a636164656e63652e6d6f65393933383838313433373030393330363331'), - ('media_proxy','media_proxy','1','563','563',X'02069e6054680b610946'), - ('media_proxy','media_proxy','1','1127','1127',X'0206bb489b717c9320e4'), - ('media_proxy','media_proxy','1','1691','1691',X'0206d75f602775b7a27c'), - ('media_proxy','media_proxy','1','2255','2255',X'0206f2c705ddca4e2b14'), - ('media_proxy','media_proxy','1','2819','2819',X'02061060db7a5151967b'), - ('media_proxy','media_proxy','1','3383','3383',X'02062cc47366f7550d22'), - ('media_proxy','media_proxy','1','3947','3947',X'020647d275ec0d781fc7'), - ('media_proxy','media_proxy','1','4511','4511',X'02066402024a7ea38249'), - ('sim_proxy','sim_proxy','1','4','4',X'025531316564343731342d636635652d346333372d393331382d376136353266383732636634'), - ('sim_proxy','sim_proxy','1','9','9',X'025533346636333932642d323263372d346337382d393063372d326536323734313535613266'), - ('sim_proxy','sim_proxy','1','14','14',X'025535396662363131392d626133392d346565382d393738612d386432376366303631393633'), - ('sim_proxy','sim_proxy','1','19','19',X'025539373066366536332d646234632d346531342d383063362d336639343938643961363665'), - ('sim_proxy','sim_proxy','1','24','24',X'025561636231613335642d313336662d343362332d626365622d326566646634616265306436'), - ('sim_proxy','sim_proxy','1','29','29',X'025563316635623735392d336136342d343633342d623634632d643461656436316539656632'), - ('sim_proxy','sim_proxy','1','34','34',X'025566323230373135632d633436332d343532622d626233612d373662646662306365353537'), - ('webhook','webhook','1','17','17',X'023331313532383834313435343038373230393936'), - ('webhook','webhook','1','35','35',X'023331313939303936333434333830343631313138'), - ('webhook','webhook','1','53','53',X'023331323331383036353337373032353736313938'), - ('webhook','webhook','1','71','71',X'023331323933373836383939343238383036363536'), - ('webhook','webhook','1','89','89',X'023331333132363031353130363535353537373132'), - ('webhook','webhook','1','107','107',X'0231323937323734313733303636333133373339'), - ('webhook','webhook','1','125','125',X'0231353239313736313536333938363832313137'), - ('webhook','webhook','1','143','143',X'0231363837303238373334333232313437333434'), - ('member_cache','member_cache','4 1','73 74','48 74',X'034b3921496f4866536e67625a6762747061747a494e3a636164656e63652e6d6f65406875636b6c65746f6e3a636164656e63652e6d6f65'), - ('member_cache','member_cache','2 1','86 87','57 87',X'034b2d214b5169714663546e764f6f4f424475746a7a3a636164656e63652e6d6f6540726e6c3a636164656e63652e6d6f65'), - ('member_cache','member_cache','4 1','101 104','68 104',X'034b43214e446249714e704a795076664b526e4e63723a636164656e63652e6d6f6540776f756e6465645f77617272696f723a6d61747269782e6f7267'), - ('member_cache','member_cache','4 1','110 113','73 113',X'034b3b214f485844457370624d485348716c4445614f3a636164656e63652e6d6f6540717561647261646963616c3a6d61747269782e6f7267'), - ('member_cache','member_cache','5 1','171 175','111 175',X'034b3b215450616f6a5454444446444847776c7276743a636164656e63652e6d6f6540766962656973766572796f3a6d61747269782e6f7267'), - ('member_cache','member_cache','39 1','180 208','116 208',X'034b4d2154716c79516d69667847556767456d64424e3a636164656e63652e6d6f6540726f626c6b796f6772653a6372616674696e67636f6d72616465732e6e6574'), - ('member_cache','member_cache','4 1','231 231','126 231',X'034b3b2156624f77675559777146614e4c5345644e413a636164656e63652e6d6f654061666c6f7765723a73796e646963617465642e676179'), - ('member_cache','member_cache','9 1','262 263','141 263',X'034b3b21594b46454e79716667696951686956496b533a636164656e63652e6d6f654062656e6d61633a636861742e62656e6d61632e78797a'), - ('member_cache','member_cache','3 1','283 283','149 283',X'034b35215a615a4d78456f52724d6d4e49554d79446c3a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'), - ('member_cache','member_cache','88 1','307 351','166 351',X'034b3b2163427874565278446c5a765356684a58564b3a636164656e63652e6d6f65406a61736b617274683a736c656570696e672e746f776e'), - ('member_cache','member_cache','11 1','408 415','177 415',X'034b5121654856655270706e6c6f57587177704a6e553a636164656e63652e6d6f65406a61636b736f6e6368656e3636363a6a61636b736f6e6368656e3636362e636f6d'), - ('member_cache','member_cache','7 1','423 424','181 424',X'034b4b2165724f7079584e465a486a48724568784e583a636164656e63652e6d6f6540616d796973636f6f6c7a3a6d61747269782e6174697573616d792e636f6d'), - ('member_cache','member_cache','96 1','436 439','187 439',X'034b4b21676865544b5a7451666c444e7070684c49673a636164656e63652e6d6f6540616c65783a73706163652e67616d65727374617665726e2e6f6e6c696e65'), - ('member_cache','member_cache','96 1','436 527','187 527',X'034b3121676865544b5a7451666c444e7070684c49673a636164656e63652e6d6f654078796c6f626f6c3a616d6265722e74656c'), - ('member_cache','member_cache','10 1','546 555','197 555',X'0351312169537958674e7851634575586f587073536e3a707573737468656361742e6f726740797562697175653a6e6f70652e63686174'), - ('member_cache','member_cache','13 1','594 601','224 601',X'034b2b216c7570486a715444537a774f744d59476d493a636164656e63652e6d6f6540656c6c69753a68617368692e7265'), - ('member_cache','member_cache','2 1','614 615','229 615',X'034b2f216d584978494644676c4861734e53427371773a636164656e63652e6d6f654077696e673a666561746865722e6f6e6c'), - ('member_cache','member_cache','4 1','616 619','230 619',X'034b2f216d616767455367755a427147425a74536e723a636164656e63652e6d6f654077696e673a666561746865722e6f6e6c'), - ('member_cache','member_cache','4 1','659 660','259 660',X'034b332172454f73706e5971644f414c4149466e69563a636164656e63652e6d6f6540656c797369613a636164656e63652e6d6f65'), - ('member_cache','member_cache','4 1','699 701','284 701',X'034b3521766e717a56767678534a586c5a504f5276533a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'), - ('member_cache','member_cache','1 1','703 703','285 703',X'034b3521767165714c474851616842464a56566779483a636164656e63652e6d6f654063696465723a6361746769726c2e636c6f7564'), - ('member_cache','member_cache','4 1','705 705','287 705',X'034b35217750454472596b77497a6f744e66706e57503a636164656e63652e6d6f6540636164656e63653a636164656e63652e6d6f65'), - ('member_cache','member_cache','35 1','709 709','288 709',X'034b2d2177574f667376757356486f4e4e567242585a3a636164656e63652e6d6f654061613a6361747669626572732e6d65'), - ('member_cache','member_cache','14 1','747 749','291 749',X'034b3721776c534544496a44676c486d42474b7254703a636164656e63652e6d6f654062616461746e616d65733a62616461742e646576'), - ('member_power','member_power','1 1','0 0','0 0',X'03350f40636164656e63653a636164656e63652e6d6f652a'), - ('file','file','1','2429','2429',X'03815f68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313039393033313838373530303033343038382f313333313336303134333238333036303833372f50584c5f32303235303132315f3230323934323137372e6a7067'), - ('file','file','1','4859','4859',X'03817568747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313134353832313533383832323637323436362f313330323232393131303834373936373331352f53637265656e73686f745f32303234313130325f3034313332365f5265646469742e6a7067'), - ('file','file','1','7289','7289',X'03815968747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f313231393439383932363436363636323433302f313239373634363930353038353636313234362f494d475f32303234313032305f3135323230302e6a7067'), - ('file','file','1','9719','9719',X'03814168747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3135393136353731343139343735393638302f313236383537333933363531343337313635392f494d475f353433362e6a7067'), - ('file','file','1','12149','12149',X'03813b68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3236363736373539303634313233383032372f313237323430333931313939383730313630392f696d6167652e706e67'), - ('file','file','1','14579','14579',X'03816b68747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3539383730363933323736303434343936392f313237373532343532343330383632373437372f45585445524e414c5f454449545f323032345f4d5f64726166745f322e646f6378'), - ('file','file','1','17009','17009',X'03815768747470733a2f2f63646e2e646973636f72646170702e636f6d2f6174746163686d656e74732f3635353231363137333639363238363734362f313333333630333132383634313036303938342f323032352d30312d32375f31372e30312e31352e706e67'), - ('file','file','1','19439','19439',X'027f68747470733a2f2f63646e2e646973636f72646170702e636f6d2f656d6f6a69732f313230323936303730343936313931323836322e706e67'), - ('message_channel','message_channel','1','40764','40764',X'023331313630333434353733303030383232383735'), - ('message_channel','message_channel','1','81529','81529',X'023331313830323437393130343238393837343333'), - ('message_channel','message_channel','1','122294','122294',X'023331313938303533383732383337363131363230'), - ('message_channel','message_channel','1','163059','163059',X'023331323237373739373330333839303738303537'), - ('message_channel','message_channel','1','203824','203824',X'023331323437303438333039303031313538373137'), - ('message_channel','message_channel','1','244589','244589',X'023331323635363939353734333034303830303034'), - ('message_channel','message_channel','1','285354','285354',X'023331323835343637363238323434313732383234'), - ('message_channel','message_channel','1','326119','326119',X'023331333038373932333935313031333736353732'), - ('lottie','lottie','1','2','2',X'0231373439303534363630373639323138363331'), - ('lottie','lottie','1','5','5',X'0231373534313037353339323030363731373635'), - ('lottie','lottie','1','8','8',X'0231373936313430363338303933343433303932'), - ('lottie','lottie','1','11','11',X'0231373936313431373032363935343835353030'), - ('lottie','lottie','1','14','14',X'0231383136303837373932323931323832393434'), - ('lottie','lottie','1','17','17',X'0231383233393736313032393736323930383636'), - ('event_message','event_message','11 1','14788 14796','14356 14796',X'03336531313532303033373639343137303839303434246d44714a474b3530424a715170394273684e534d64365f3768494354776e70362d793130786d6669766563'), - ('event_message','event_message','11 1','33806 33815','32914 33815',X'033365313135373630333638373035373836363736322459526f7139484b376e55677a397668796f6e3053424a49497978497a5750734e4f6e39756f765644664d45'), - ('event_message','event_message','1 1','42546 42546','41335 42546',X'03336531313630363236383737353835363938393237245033436e4f6d6a35462d6939454e4e79586b70796f5679306b74324a654464764276326e746d33566a4355'), - ('event_message','event_message','2 1','85093 85093','81999 85093',X'0333653131383039393939363531323930343831303424496748666562784533746e623047412d534f7176594a354e4e55385735706c68523159676854636a554734'), - ('event_message','event_message','11 1','116157 116165','111525 116165',X'03336531313933363837333730363137333237363536245a32305734766c737079566e387a6e2d526a6f64694a51745f5a7851644f5f33744e415169453755356477'), - ('event_message','event_message','1 1','127640 127640','122328 127640',X'0333653132303132353637313733373633303332323624702d626f415672476a4b45327a7158664f3738387a5a597a376a42624648717431334d386464705467476f'), - ('event_message','event_message','16 1','140270 140281','134379 140281',X'03336531323039333735363534373138343830343235246c374a4f7543526b7756306d627a69356e5843496b4538416a6951374f67473455456c2d7053445a516649'), - ('event_message','event_message','11 1','162065 162071','154933 162071',X'0333653132323434383135393033313937373537373424674c77513179796e4b6d5859496b5a597a4a55627a66557a55552d714c4b5f524f454e4250325f6e44766b'), - ('event_message','event_message','1 1','170187 170187','162530 170187',X'0333653132323937373533363839343532373038333424656c6c76416a544a5847627936767249767470677a555572787231716f5a75536e50525f474b4e35455945'), - ('event_message','event_message','11 1','178736 178736','170762 178736',X'03336531323333353238303533323338333337353537242d39304668552d36455373594b6435484d7237666d6a414a5f6a576149616e356c4776384e655436564959'), - ('event_message','event_message','10 1','180317 180325','172228 180325',X'03336531323334363237303030393333383130323637246a4679416449665a4f54432d2d735971715472473735374c7a50504f34386439657963485477686d797751'), - ('event_message','event_message','1 1','212734 212734','203278 212734',X'03336531323438303131373930303633373637363734246557493950456f4d576b614b4d33416a7030782d6d455f6c4b4a6c495f6d6d44686f6379464b5170534f59'), - ('event_message','event_message','11 1','228100 228103','217856 228103',X'033365313235333734353736363733373132313336322447326a7a746e5977716a3676304a7a4168576e30725950596470667a5f496f76426771574a4876434c516b'), - ('event_message','event_message','11 1','240172 240181','229123 240181',X'033365313235393239383538323233303630313735382472635679454c5a76647453547a37366f3669434a4f614a316a756f683835535778494d546c5072517a676f'), - ('event_message','event_message','10 1','240259 240264','229132 240264',X'0333653132353933303030343035333535373235323024535849376a465f696e71424d714c4f564b347659746852644b31724747502d435a5f355a354f6b774e3751'), - ('event_message','event_message','2 1','255280 255281','243230 255281',X'03336531323636373837343338353137343234313738246e6e4e576f526a54495757723441463770625454746b7a73784c4d336b312d6d6645665031496b43586e59'), - ('event_message','event_message','1 1','297828 297828','283436 297828',X'0333653132383637303937353334363834323032313024544342465970356f39767a2d4f6b2d33654f4e433772426d354966615934476b48536e58445257474f4767'), - ('event_message','event_message','1 1','340375 340375','323489 340375',X'033365313330393336363936343530353934303030392431664536386d2d50546e786d5474345458584c35754847594139353779396c76582d50797150496d395f30'), - ('event_message','event_message','11 1','343791 343799','326730 343799',X'03336531333131353731333337393331363537323737246731767241315269725951592d3933304f6973587142372d6961686e34684e492d6462374952714176616b'), - ('event_message','event_message','10 1','363207 363216','344868 363216',X'0333653133323434393732323633393438393834393524784f78636e4749364269545941734d7a4f7557316f526e68356b675378544e30466442386a3037695f4f67'), - ('event_message','event_message','10 1','363219 363219','344871 363219',X'033365313332343439373532363733323039393732352432586f7938567937643843375a47767472657966326b4c4f4159397546386b4755364c3642435f446b6d77'), - ('event_message','event_message','10 1','363340 363343','344918 363343',X'03336531333234353037313732333634373530393830244f46645731396649534176706d34646c36367a2d5543337236432d436354506e34752d63444f7036733345'), - ('event_message','event_message','11 1','369452 369456','350712 369456',X'0333653133323736353831313733343439323336383024574d774330644574417277375f4562554a534465546532577174506d3747584347774570646c4f79326d30'), - ('event_message','event_message','10 1','372353 372356','353425 372356',X'0333653133323933313737353039313234353035373224366259364d313472667163486d5854476349716d4a4366467471796839794472375a6a487463715a6d4f34'), - ('sim_member','sim_member','225 1','0 12','0 12',X'034b4721414956694e775a64636b4652764c4f4567433a636164656e63652e6d6f65405f6f6f79655f616b6972615f6e6965723a636164656e63652e6d6f65'), - ('sim_member','sim_member','2 1','319 319','14 319',X'034b43214456706f6e54524d56456570486378744c423a636164656e63652e6d6f65405f6f6f79655f656e746f6c6f6d613a636164656e63652e6d6f65'), - ('sim_member','sim_member','68 1','391 440','26 440',X'034b4921457a54624a496c496d45534f746b4e644e4a3a636164656e63652e6d6f65405f6f6f79655f73617475726461797465643a636164656e63652e6d6f65'), - ('sim_member','sim_member','8 1','638 639','59 639',X'034b3f21497a4f675169446e757346516977796d614c3a636164656e63652e6d6f65405f6f6f79655f636f6f6b69653a636164656e63652e6d6f65'), - ('sim_member','sim_member','31 1','743 771','86 771',X'034b49214d5071594e414a62576b72474f544a7461703a636164656e63652e6d6f65405f6f6f79655f746865666f6f6c323239343a636164656e63652e6d6f65'), - ('sim_member','sim_member','26 1','774 787','87 787',X'034b49214d687950614b4250506f496c7365794d6d743a636164656e63652e6d6f65405f6f6f79655f6b79757567727970686f6e3a636164656e63652e6d6f65'), - ('sim_member','sim_member','27 1','877 881','104 881',X'034b45215063734371724f466a48476f41424270414c3a636164656e63652e6d6f65405f6f6f79655f62696c6c795f626f623a636164656e63652e6d6f65'), - ('sim_member','sim_member','16 1','956 959','117 959',X'034b43215158526f4a777a63506d5047546d454b454d3a636164656e63652e6d6f65405f6f6f79655f626f7472616334723a636164656e63652e6d6f65'), - ('sim_member','sim_member','32 1','997 1012','121 1012',X'034b3f215170676c734e587a4c7751594d4c6c734f503a636164656e63652e6d6f65405f6f6f79655f6a75746f6d693a636164656e63652e6d6f65'), - ('sim_member','sim_member','7 1','1274 1279','157 1279',X'034b4121554d6f6e68556765644d47585a78466658753a636164656e63652e6d6f65405f6f6f79655f6d696e696d75733a636164656e63652e6d6f65'), - ('sim_member','sim_member','27 1','1415 1439','188 1439',X'034b3d21595868717249786d586e47736961796a59783a636164656e63652e6d6f65405f6f6f79655f73746161663a636164656e63652e6d6f65'), - ('sim_member','sim_member','16 1','1597 1599','217 1599',X'034b512163466a4479477274466d48796d794c6652453a636164656e63652e6d6f65405f6f6f79655f626f6a61636b5f686f7273656d616e3a636164656e63652e6d6f65'), - ('sim_member','sim_member','27 1','1758 1761','248 1761',X'034b3d2168665a74624d656f5355564e424850736a743a636164656e63652e6d6f65405f6f6f79655f617a7572653a636164656e63652e6d6f65'), - ('sim_member','sim_member','25 1','1865 1886','270 1886',X'034b3f216b4c52714b4b555158636962494d744f706c3a636164656e63652e6d6f65405f6f6f79655f7361796f72693a636164656e63652e6d6f65'), - ('sim_member','sim_member','19 1','1918 1919','276 1919',X'034b3d216b68497350756c465369736d43646c596e493a636164656e63652e6d6f65405f6f6f79655f617a7572653a636164656e63652e6d6f65'), - ('sim_member','sim_member','33 1','1986 2015','286 2015',X'034b3d216d5451744d736a534c4f646c576f7265594d3a636164656e63652e6d6f65405f6f6f79655f73746161663a636164656e63652e6d6f65'), - ('sim_member','sim_member','37 1','2027 2028','289 2028',X'034b4d216d616767455367755a427147425a74536e723a636164656e63652e6d6f65405f6f6f79655f2e7265616c2e706572736f6e2e3a636164656e63652e6d6f65'), - ('sim_member','sim_member','28 1','2117 2130','297 2130',X'034b3f216e4e595a794b6f4e70797859417a50466f733a636164656e63652e6d6f65405f6f6f79655f6a75746f6d693a636164656e63652e6d6f65'), - ('sim_member','sim_member','20 1','2230 2239','310 2239',X'034b41217046504c7270594879487a784e4c69594b413a636164656e63652e6d6f65405f6f6f79655f686578676f61743a636164656e63652e6d6f65'), - ('sim_member','sim_member','30 1','2381 2398','332 2398',X'034b3b21717a44626c4b6c69444c577a52524f6e465a3a636164656e63652e6d6f65405f6f6f79655f6d6e696b3a636164656e63652e6d6f65'), - ('sim_member','sim_member','38 1','2490 2518','344 2518',X'034b3d2173445250714549546e4f4e57474176496b423a636164656e63652e6d6f65405f6f6f79655f727974686d3a636164656e63652e6d6f65'), - ('sim_member','sim_member','11 1','2555 2559','358 2559',X'034b4321746751436d526b426e6474516362687150583a636164656e63652e6d6f65405f6f6f79655f6a6f7365707065793a636164656e63652e6d6f65'), - ('sim_member','sim_member','47 1','2633 2666','377 2666',X'034b4b217750454472596b77497a6f744e66706e57503a636164656e63652e6d6f65405f6f6f79655f6e61706f6c656f6e333038393a636164656e63652e6d6f65'), - ('sim_member','sim_member','52 1','2817 2837','414 2837',X'034b43217a66654e574d744b4f764f48766f727979563a636164656e63652e6d6f65405f6f6f79655f696e736f676e69613a636164656e63652e6d6f65'), - ('guild_space','guild_space','1','3','3',X'0231313132373630363639313738323431303234'), - ('guild_space','guild_space','1','7','7',X'023331313534383638343234373234343633363837'), - ('guild_space','guild_space','1','11','11',X'023331323139303338323637383430393235383138'), - ('guild_space','guild_space','1','15','15',X'023331323839353939363232353930383930313335'), - ('guild_space','guild_space','1','19','19',X'0231323733383737363437323234393935383431'), - ('guild_space','guild_space','1','23','23',X'0231353239313736313536333938363832313135'), - ('guild_space','guild_space','1','27','27',X'0231373535303134333534373334313533383138'), - ('guild_space','guild_space','1','31','31',X'0231393933383838313432343535323130303834'), - ('guild_active','guild_active','1','3','3',X'0231313132373630363639313738323431303234'), - ('guild_active','guild_active','1','7','7',X'023331313534383638343234373234343633363837'), - ('guild_active','guild_active','1','11','11',X'023331323139303338323637383430393235383138'), - ('guild_active','guild_active','1','15','15',X'023331323839353939363232353930383930313335'), - ('guild_active','guild_active','1','19','19',X'023331333333323139363936393333323038303934'), - ('guild_active','guild_active','1','23','23',X'0231343735353939303338353336373434393630'), - ('guild_active','guild_active','1','27','27',X'022f3636313932393535373737343836383438'), - ('guild_active','guild_active','1','31','31',X'0231383737303635303431393930353136373637'), - ('emoji','emoji','1','284','284',X'023331313132323031303430303637303339333332'), - ('emoji','emoji','1','569','569',X'023331323334393032303131393436383630363638'), - ('emoji','emoji','1','854','854',X'0231323735313734373438353034313935303732'), - ('emoji','emoji','1','1139','1139',X'0231333837343730383630303134393737303235'), - ('emoji','emoji','1','1424','1424',X'0231353435363639393734303130383838313932'), - ('emoji','emoji','1','1709','1709',X'0231363339313034333030393139393437323634'), - ('emoji','emoji','1','1994','1994',X'0231373532363932363230303638373832313231'), - ('emoji','emoji','1','2279','2279',X'0231383935343737353331303434363138323430'), - ('auto_emoji','auto_emoji','1','0','0',X'02114c31'), - ('auto_emoji','auto_emoji','1','1','1',X'02114c32'), - ('auto_emoji','auto_emoji','1','2','2',X'020f5f'); - -ANALYZE sqlite_schema; - -COMMIT; diff --git a/src/db/migrations/0018-add-custom-topic-to-channel-room.sql b/src/db/migrations/0018-add-custom-topic-to-channel-room.sql deleted file mode 100644 index c33d21c..0000000 --- a/src/db/migrations/0018-add-custom-topic-to-channel-room.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN TRANSACTION; - -ALTER TABLE channel_room ADD COLUMN custom_topic INTEGER DEFAULT 0; - -COMMIT; diff --git a/src/db/migrations/0019-add-invite.sql b/src/db/migrations/0019-add-invite.sql deleted file mode 100644 index 6ad03f9..0000000 --- a/src/db/migrations/0019-add-invite.sql +++ /dev/null @@ -1,13 +0,0 @@ -BEGIN TRANSACTION; - -CREATE TABLE "invite" ( - "mxid" TEXT NOT NULL, - "room_id" TEXT NOT NULL, - "type" TEXT, - "name" TEXT, - "topic" TEXT, - "avatar" TEXT, - PRIMARY KEY("mxid","room_id") -) WITHOUT ROWID; - -COMMIT; diff --git a/src/db/migrations/0020-add-presence-to-guild-space.sql b/src/db/migrations/0020-add-presence-to-guild-space.sql deleted file mode 100644 index ea4c908..0000000 --- a/src/db/migrations/0020-add-presence-to-guild-space.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN TRANSACTION; - -ALTER TABLE guild_space ADD COLUMN presence INTEGER NOT NULL DEFAULT 1; - -COMMIT; diff --git a/src/db/migrations/0021-add-url-preview-to-guild-space.sql b/src/db/migrations/0021-add-url-preview-to-guild-space.sql deleted file mode 100644 index 64bc0dd..0000000 --- a/src/db/migrations/0021-add-url-preview-to-guild-space.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN TRANSACTION; - -ALTER TABLE guild_space ADD COLUMN url_preview INTEGER NOT NULL DEFAULT 1; - -COMMIT; diff --git a/src/db/migrations/0022-auto-emoji-without-guild.sql b/src/db/migrations/0022-auto-emoji-without-guild.sql deleted file mode 100644 index 1d23c0d..0000000 --- a/src/db/migrations/0022-auto-emoji-without-guild.sql +++ /dev/null @@ -1,11 +0,0 @@ -BEGIN TRANSACTION; - -DROP TABLE auto_emoji; - -CREATE TABLE auto_emoji ( - name TEXT NOT NULL, - emoji_id TEXT NOT NULL, - PRIMARY KEY (name) -) WITHOUT ROWID; - -COMMIT; diff --git a/src/db/migrations/0023-add-original-encoding-to-reaction.sql b/src/db/migrations/0023-add-original-encoding-to-reaction.sql deleted file mode 100644 index e42e4e1..0000000 --- a/src/db/migrations/0023-add-original-encoding-to-reaction.sql +++ /dev/null @@ -1,5 +0,0 @@ -BEGIN TRANSACTION; - -ALTER TABLE reaction ADD COLUMN original_encoding TEXT; - -COMMIT; diff --git a/src/db/migrations/0024-add-direct.sql b/src/db/migrations/0024-add-direct.sql deleted file mode 100644 index 94dc4ae..0000000 --- a/src/db/migrations/0024-add-direct.sql +++ /dev/null @@ -1,9 +0,0 @@ -BEGIN TRANSACTION; - -CREATE TABLE direct ( - mxid TEXT NOT NULL, - room_id TEXT NOT NULL, - PRIMARY KEY (mxid) -) WITHOUT ROWID; - -COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index 79fd501..c235e99 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -1,9 +1,4 @@ export type Models = { - auto_emoji: { - name: string - emoji_id: string - } - channel_room: { channel_id: string room_id: string @@ -15,20 +10,6 @@ export type Models = { speedbump_id: string | null speedbump_webhook_id: string | null speedbump_checked: number | null - guild_id: string | null - custom_topic: number - } - - direct: { - mxid: string - room_id: string - } - - emoji: { - emoji_id: string - name: string - animated: number - mxc_url: string } event_message: { @@ -50,8 +31,6 @@ export type Models = { guild_id: string space_id: string privacy_level: number - presence: 0 | 1 - url_preview: 0 | 1 } guild_active: { @@ -59,23 +38,11 @@ export type Models = { autocreate: 0 | 1 } - invite: { - mxid: string - room_id: string - type: string | null - name: string | null - avatar: string | null - } - lottie: { sticker_id: string mxc_url: string } - media_proxy: { - permitted_hash: number - } - member_cache: { room_id: string mxid: string @@ -120,11 +87,27 @@ export type Models = { webhook_token: string } + emoji: { + emoji_id: string + name: string + animated: number + mxc_url: string + } + reaction: { hashed_event_id: number message_id: string encoded_emoji: string - original_encoding: string | null + } + + auto_emoji: { + name: string + emoji_id: string + guild_id: string + } + + media_proxy: { + permitted_hash: number } } diff --git a/src/db/orm.test.js b/src/db/orm.test.js index 4549b9e..a8f10f4 100644 --- a/src/db/orm.test.js +++ b/src/db/orm.test.js @@ -6,17 +6,17 @@ const data = require("../../test/data") const {db, select, from} = require("../passthrough") test("orm: select: get works", t => { - const row = select("guild_space", "guild_id", {}, "WHERE space_id = ?").get("!jjmvBegULiLucuWEHU:cadence.moe") + const row = select("guild_space", "guild_id", {}, "WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe") t.equal(row?.guild_id, data.guild.general.id) }) test("orm: from: get works", t => { - const row = from("guild_space").select("guild_id").and("WHERE space_id = ?").get("!jjmvBegULiLucuWEHU:cadence.moe") + const row = from("guild_space").select("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe") t.equal(row?.guild_id, data.guild.general.id) }) test("orm: select: get pluck works", t => { - const guildID = select("guild_space", "guild_id", {}, "WHERE space_id = ?").pluck().get("!jjmvBegULiLucuWEHU:cadence.moe") + const guildID = select("guild_space", "guild_id", {}, "WHERE space_id = ?").pluck().get("!jjWAGMeQdNrVZSSfvz:cadence.moe") t.equal(guildID, data.guild.general.id) }) @@ -36,7 +36,7 @@ test("orm: select: in array works", t => { }) test("orm: from: get pluck works", t => { - const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjmvBegULiLucuWEHU:cadence.moe") + const guildID = from("guild_space").pluck("guild_id").and("WHERE space_id = ?").get("!jjWAGMeQdNrVZSSfvz:cadence.moe") t.equal(guildID, data.guild.general.id) }) diff --git a/src/discord/interactions/invite.test.js b/src/discord/interactions/invite.test.js index a2393e5..8290718 100644 --- a/src/discord/interactions/invite.test.js +++ b/src/discord/interactions/invite.test.js @@ -59,7 +59,7 @@ test("invite: checks if guild exists", async t => { // it might not exist if the }) test("invite: checks if channel exists or is autocreatable", async t => { - db.prepare("UPDATE guild_active SET autocreate = 0 WHERE guild_id = '112760669178241024'").run() + db.prepare("UPDATE guild_active SET autocreate = 0").run() const msgs = await fromAsync(_interact({ data: { options: [{ @@ -72,7 +72,7 @@ test("invite: checks if channel exists or is autocreatable", async t => { guild_id: "112760669178241024" }, {})) t.equal(msgs[0].createInteractionResponse.data.content, "This channel isn't bridged, so you can't invite Matrix users yet. Try turning on automatic room-creation or link a Matrix room in the website.") - db.prepare("UPDATE guild_active SET autocreate = 1 WHERE guild_id = '112760669178241024'").run() + db.prepare("UPDATE guild_active SET autocreate = 1").run() }) test("invite: checks if user is already invited to space", async t => { @@ -91,7 +91,7 @@ test("invite: checks if user is already invited to space", async t => { api: { getStateEvent: async (roomID, type, stateKey) => { called++ - t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID + t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID t.equal(type, "m.room.member") t.equal(stateKey, "@cadence:cadence.moe") return { @@ -121,14 +121,14 @@ test("invite: invites if user is not in space", async t => { api: { getStateEvent: async (roomID, type, stateKey) => { called++ - t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID + t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID t.equal(type, "m.room.member") t.equal(stateKey, "@cadence:cadence.moe") throw new MatrixServerError("State event doesn't exist or something") }, inviteToRoom: async (roomID, mxid) => { called++ - t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID + t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID t.equal(mxid, "@cadence:cadence.moe") } } @@ -155,7 +155,7 @@ test("invite: prompts to invite to room (if never joined)", async t => { called++ t.equal(type, "m.room.member") t.equal(stateKey, "@cadence:cadence.moe") - if (roomID === "!jjmvBegULiLucuWEHU:cadence.moe") { // space ID + if (roomID === "!jjWAGMeQdNrVZSSfvz:cadence.moe") { // space ID return { displayname: "cadence", membership: "join" @@ -188,7 +188,7 @@ test("invite: prompts to invite to room (if left)", async t => { called++ t.equal(type, "m.room.member") t.equal(stateKey, "@cadence:cadence.moe") - if (roomID === "!jjmvBegULiLucuWEHU:cadence.moe") { // space ID + if (roomID === "!jjWAGMeQdNrVZSSfvz:cadence.moe") { // space ID return { displayname: "cadence", membership: "join" diff --git a/src/discord/interactions/permissions.test.js b/src/discord/interactions/permissions.test.js index a7da859..64cda80 100644 --- a/src/discord/interactions/permissions.test.js +++ b/src/discord/interactions/permissions.test.js @@ -57,7 +57,7 @@ test("permissions: reports permissions of selected matrix user (implicit default }, async getStateEvent(roomID, type, key) { called++ - t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID + t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID t.equal(type, "m.room.power_levels") t.equal(key, "") return { @@ -91,7 +91,7 @@ test("permissions: reports permissions of selected matrix user (moderator)", asy }, async getStateEvent(roomID, type, key) { called++ - t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID + t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID t.equal(type, "m.room.power_levels") t.equal(key, "") return { @@ -127,7 +127,7 @@ test("permissions: reports permissions of selected matrix user (admin)", async t }, async getStateEvent(roomID, type, key) { called++ - t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID + t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID t.equal(type, "m.room.power_levels") t.equal(key, "") return { @@ -159,7 +159,7 @@ test("permissions: can update user to moderator", async t => { api: { async setUserPowerCascade(roomID, mxid, power) { called++ - t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID + t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID t.equal(mxid, "@cadence:cadence.moe") t.equal(power, 50) } @@ -186,7 +186,7 @@ test("permissions: can update user to default", async t => { api: { async setUserPowerCascade(roomID, mxid, power) { called++ - t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") // space ID + t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID t.equal(mxid, "@cadence:cadence.moe") t.equal(power, 0) } diff --git a/src/discord/register-interactions.js b/src/discord/register-interactions.js index 0c5a0b0..7b1e52d 100644 --- a/src/discord/register-interactions.js +++ b/src/discord/register-interactions.js @@ -64,9 +64,7 @@ discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{ }] } ] -}]).catch(e => { - console.error(e) -}) +}]) async function dispatchInteraction(interaction) { const interactionId = interaction.data.custom_id || interaction.data.name diff --git a/src/discord/utils.js b/src/discord/utils.js index 963f0b8..dea05ae 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -136,24 +136,6 @@ function getPublicUrlForCdn(url) { return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}` } -/** - * @param {string} oldTimestamp - * @param {string} newTimestamp - * @returns {string} "a x-day-old unbridged message" - */ -function howOldUnbridgedMessage(oldTimestamp, newTimestamp) { - const dateDifference = new Date(newTimestamp).getTime() - new Date(oldTimestamp).getTime() - const oneHour = 60 * 60 * 1000 - if (dateDifference < oneHour) { - return "an unbridged message" - } else if (dateDifference < 25 * oneHour) { - var dateDisplay = `a ${Math.floor(dateDifference / oneHour)}-hour-old unbridged message` - } else { - var dateDisplay = `a ${Math.round(dateDifference / (24 * oneHour))}-day-old unbridged message` - } - return dateDisplay -} - module.exports.getPermissions = getPermissions module.exports.hasPermission = hasPermission module.exports.hasSomePermissions = hasSomePermissions @@ -163,4 +145,3 @@ module.exports.isEphemeralMessage = isEphemeralMessage module.exports.snowflakeToTimestampExact = snowflakeToTimestampExact module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact module.exports.getPublicUrlForCdn = getPublicUrlForCdn -module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage diff --git a/src/m2d/actions/add-reaction.js b/src/m2d/actions/add-reaction.js index 277c475..cfd471b 100644 --- a/src/m2d/actions/add-reaction.js +++ b/src/m2d/actions/add-reaction.js @@ -20,25 +20,12 @@ async function addReaction(event) { if (!messageID) return // Nothing can be done if the parent message was never bridged. const key = event.content["m.relates_to"].key - const discordPreferredEncoding = await emoji.encodeEmoji(key, event.content.shortcode) + const discordPreferredEncoding = emoji.encodeEmoji(key, event.content.shortcode) if (!discordPreferredEncoding) return - try { - await discord.snow.channel.createReaction(channelID, messageID, discordPreferredEncoding) // acting as the discord bot itself - } catch (e) { - if (e.message?.includes("Maximum number of reactions reached")) { - // we'll silence this particular error to avoid spamming the chat - // not adding it to the database otherwise a m->d removal would try calling the API - return - } - if (e.message?.includes("Unknown Emoji")) { - // happens if a matrix user tries to add on to a super reaction - return - } - throw e - } + await discord.snow.channel.createReaction(channelID, messageID, discordPreferredEncoding) // acting as the discord bot itself - db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji, original_encoding) VALUES (?, ?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding, key) + db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji) VALUES (?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding) } module.exports.addReaction = addReaction diff --git a/src/m2d/actions/channel-webhook.js b/src/m2d/actions/channel-webhook.js index 09c642b..13c3ab1 100644 --- a/src/m2d/actions/channel-webhook.js +++ b/src/m2d/actions/channel-webhook.js @@ -2,7 +2,7 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") -const stream = require("stream") +const {Readable} = require("stream") const passthrough = require("../../passthrough") const {discord, db, select} = passthrough @@ -57,7 +57,7 @@ async function withWebhook(channelID, callback) { /** * @param {string} channelID - * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}} data + * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]}} data * @param {string} [threadID] */ async function sendMessageWithWebhook(channelID, data, threadID) { @@ -70,7 +70,7 @@ async function sendMessageWithWebhook(channelID, data, threadID) { /** * @param {string} channelID * @param {string} messageID - * @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}} data + * @param {DiscordTypes.RESTPatchAPIWebhookWithTokenMessageJSONBody & {files?: {name: string, file: Buffer | Readable}[]}} data * @param {string} [threadID] */ async function editMessageWithWebhook(channelID, messageID, data, threadID) { diff --git a/src/m2d/actions/emoji-sheet.js b/src/m2d/actions/emoji-sheet.js index a63f0b0..c81960d 100644 --- a/src/m2d/actions/emoji-sheet.js +++ b/src/m2d/actions/emoji-sheet.js @@ -1,6 +1,9 @@ // @ts-check -const stream = require("stream") +const assert = require("assert") +const fetch = require("node-fetch").default + +const utils = require("../converters/utils") const {sync} = require("../../passthrough") /** @type {import("../converters/emoji-sheet")} */ @@ -15,15 +18,16 @@ const api = sync.require("../../matrix/api") */ async function getAndConvertEmoji(mxc) { const abortController = new AbortController() + /** @type {import("node-fetch").Response} */ // If it turns out to be a GIF, we want to abandon the connection without downloading the whole thing. // If we were using connection pooling, we would be forced to download the entire GIF. // So we set no agent to ensure we are not connection pooling. - const res = await api.getMedia(mxc, {signal: abortController.signal}) - const readable = stream.Readable.fromWeb(res.body) - return emojiSheetConverter.convertImageStream(readable, () => { + // @ts-ignore the signal is slightly different from the type it wants (still works fine) + const res = await api.getMedia(mxc, {agent: false, signal: abortController.signal}) + return emojiSheetConverter.convertImageStream(res.body, () => { abortController.abort() - readable.emit("end") - readable.on("error", () => {}) // DOMException [AbortError]: This operation was aborted + res.body.pause() + res.body.emit("end") }) } diff --git a/src/m2d/actions/redact.js b/src/m2d/actions/redact.js index 1f6cef8..5a12d5a 100644 --- a/src/m2d/actions/redact.js +++ b/src/m2d/actions/redact.js @@ -13,6 +13,7 @@ const utils = sync.require("../converters/utils") */ async function deleteMessage(event) { const rows = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: event.redacts}).all() + db.prepare("DELETE FROM event_message WHERE event_id = ?").run(event.redacts) for (const row of rows) { db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(row.message_id) await discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason) diff --git a/src/m2d/actions/send-event.js b/src/m2d/actions/send-event.js index 6b4eb26..0a270a0 100644 --- a/src/m2d/actions/send-event.js +++ b/src/m2d/actions/send-event.js @@ -2,9 +2,10 @@ const Ty = require("../../types") const DiscordTypes = require("discord-api-types/v10") -const stream = require("stream") +const {Readable} = require("stream") const assert = require("assert").strict const crypto = require("crypto") +const fetch = require("node-fetch").default const passthrough = require("../../passthrough") const {sync, discord, db, select} = passthrough @@ -22,8 +23,8 @@ const editMessage = sync.require("../../d2m/actions/edit-message") const emojiSheet = sync.require("../actions/emoji-sheet") /** - * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | stream.Readable})[]}} message - * @returns {Promise } + * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | Readable})[]}} message + * @returns {Promise } */ async function resolvePendingFiles(message) { if (!message.pendingFiles) return message @@ -37,14 +38,16 @@ async function resolvePendingFiles(message) { if ("key" in p) { // Encrypted file const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url")) - await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body).pipe(d)) + // @ts-ignore + await api.getMedia(p.mxc).then(res => res.body.pipe(d)) return { name: p.name, file: d } } else { // Unencrypted file - const body = await api.getMedia(p.mxc).then(res => stream.Readable.fromWeb(res.body)) + /** @type {Readable} */ // @ts-ignore + const body = await api.getMedia(p.mxc).then(res => res.body) return { name: p.name, file: body @@ -62,7 +65,7 @@ async function resolvePendingFiles(message) { /** @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker} event */ async function sendEvent(event) { const row = select("channel_room", ["channel_id", "thread_parent"], {room_id: event.room_id}).get() - if (!row) return [] // allow the bot to exist in unbridged rooms, just don't do anything with it + if (!row) return // allow the bot to exist in unbridged rooms, just don't do anything with it let channelID = row.channel_id let threadID = undefined if (row.thread_parent) { @@ -99,13 +102,14 @@ async function sendEvent(event) { for (const id of messagesToDelete) { db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(id) + db.prepare("DELETE FROM event_message WHERE message_id = ?").run(id) await channelWebhook.deleteMessageWithWebhook(channelID, id, threadID) } for (const message of messagesToSend) { const reactionPart = messagesToEdit.length === 0 && message === messagesToSend[messagesToSend.length - 1] ? 0 : 1 const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID) - db.prepare("INSERT INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID) + db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, threadID || channelID) db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content["msgtype"] || null, messageResponse.id, eventPart, reactionPart) // source 0 = matrix eventPart = 1 diff --git a/src/m2d/actions/setup-emojis.js b/src/m2d/actions/setup-emojis.js deleted file mode 100644 index ba2c045..0000000 --- a/src/m2d/actions/setup-emojis.js +++ /dev/null @@ -1,26 +0,0 @@ -// @ts-check - -const fs = require("fs") -const {join} = require("path") - -const passthrough = require("../../passthrough") - -const {id} = require("../../../addbot") - -async function setupEmojis() { - const {discord, db} = passthrough - const emojis = await discord.snow.assets.getAppEmojis(id) - for (const name of ["L1", "L2"]) { - const existing = emojis.items.find(e => e.name === name) - if (existing) { - db.prepare("REPLACE INTO auto_emoji (name, emoji_id) VALUES (?, ?)").run(existing.name, existing.id) - } else { - const filename = join(__dirname, "../../../docs/img", `${name}.png`) - const data = fs.readFileSync(filename, null) - const uploaded = await discord.snow.assets.createAppEmoji(id, {name, image: "data:image/png;base64," + data.toString("base64")}) - db.prepare("REPLACE INTO auto_emoji (name, emoji_id) VALUES (?, ?)").run(uploaded.name, uploaded.id) - } - } -} - -module.exports.setupEmojis = setupEmojis diff --git a/src/m2d/converters/emoji-sheet.js b/src/m2d/converters/emoji-sheet.js index 16d5065..db5b06f 100644 --- a/src/m2d/converters/emoji-sheet.js +++ b/src/m2d/converters/emoji-sheet.js @@ -1,7 +1,6 @@ // @ts-check const assert = require("assert").strict -const stream = require("stream") const {pipeline} = require("stream").promises const sharp = require("sharp") const {GIFrame} = require("@cloudrac3r/giframe") @@ -49,7 +48,7 @@ async function compositeMatrixEmojis(mxcs, mxcDownloader) { } /** - * @param {stream.Readable} streamIn + * @param {import("node-fetch").Response["body"]} streamIn * @param {() => any} stopStream * @returns {Promise } Uncompressed PNG image */ diff --git a/src/m2d/converters/emoji.js b/src/m2d/converters/emoji.js index 63e53a9..66a690c 100644 --- a/src/m2d/converters/emoji.js +++ b/src/m2d/converters/emoji.js @@ -1,98 +1,58 @@ // @ts-check -const fsp = require("fs").promises -const {join} = require("path") -const emojisp = fsp.readFile(join(__dirname, "emojis.txt"), "utf8").then(content => content.split("\n")) +const assert = require("assert").strict +const Ty = require("../../types") const passthrough = require("../../passthrough") -const {select} = passthrough - +const {sync, select} = passthrough /** * @param {string} input * @param {string | null | undefined} shortcode * @returns {string?} */ -function encodeCustomEmoji(input, shortcode) { - // Custom emoji - let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get() - if (!row && shortcode) { - // Use the name to try to find a known emoji with the same name. - const name = shortcode.replace(/^:|:$/g, "") - row = select("emoji", ["emoji_id", "name"], {name: name}).get() - } - if (!row) { - // We don't have this emoji and there's no realistic way to just-in-time upload a new emoji somewhere. Sucks! - return null - } - return encodeURIComponent(`${row.name}:${row.emoji_id}`) -} - -/** - * @param {string} input - * @returns {Promise } URL encoded! - */ -async function encodeDefaultEmoji(input) { - // Default emoji - - // Shortcut: If there are ASCII letters then it's not an emoji, it's a freeform Matrix text reaction. - // (Regional indicator letters are not ASCII. ASCII digits might be part of an emoji.) - if (input.match(/[A-Za-z]/)) return null - - // Check against the dataset - const emojis = await emojisp - const encoded = encodeURIComponent(input) - - // Best case scenario: they reacted with an exact replica of a valid emoji. - if (emojis.includes(input)) return encoded - - // Maybe it has some extraneous \ufe0f or \ufe0e (at the end or in the middle), and it'll be valid if they're removed. - const trimmed = input.replace(/\ufe0e|\ufe0f/g, "") - const trimmedEncoded = encodeURIComponent(trimmed) - if (trimmed !== input) { - if (emojis.includes(trimmed)) return trimmedEncoded - } - - // Okay, well, maybe it was already missing one and it actually needs an extra \ufe0f, and it'll be valid if that's added. - else { - const appended = input + "\ufe0f" - const appendedEncoded = encodeURIComponent(appended) - if (emojis.includes(appended)) return appendedEncoded - } - - // Hmm, so adding or removing that from the end didn't help, but maybe there needs to be one in the middle? We can try some heuristics. - // These heuristics come from executing scripts/emoji-surrogates-statistics.js. - if (trimmedEncoded.length <= 21 && trimmed.match(/^[*#0-9]/)) { // ->19: Keycap digit? 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ *️⃣ #️⃣ - const keycap = trimmed[0] + "\ufe0f" + trimmed.slice(1) - if (emojis.includes(keycap)) return encodeURIComponent(keycap) - } else if (trimmedEncoded.length === 27 && trimmed[0] === "⛹") { // ->45: ⛹️♀️ ⛹️♂️ - const balling = trimmed[0] + "\ufe0f" + trimmed.slice(1) + "\ufe0f" - if (emojis.includes(balling)) return encodeURIComponent(balling) - } else if (trimmedEncoded.length === 30) { // ->39: ⛓️💥 ❤️🩹 ❤️🔥 or ->48: 🏳️⚧️ 🏌️♀️ 🕵️♀️ 🏋️♀️ and gender variants - const thriving = trimmed[0] + "\ufe0f" + trimmed.slice(1) - if (emojis.includes(thriving)) return encodeURIComponent(thriving) - const powerful = trimmed.slice(0, 2) + "\ufe0f" + trimmed.slice(2) + "\ufe0f" - if (emojis.includes(powerful)) return encodeURIComponent(powerful) - } else if (trimmedEncoded.length === 51 && trimmed[3] === "❤") { // ->60: 👩❤️👨 👩❤️👩 👨❤️👨 - const yellowRomance = trimmed.slice(0, 3) + "❤\ufe0f" + trimmed.slice(4) - if (emojis.includes(yellowRomance)) return encodeURIComponent(yellowRomance) - } - - // there are a few more longer ones but I got bored - return null -} - -/** - * @param {string} input - * @param {string | null | undefined} shortcode - * @returns {Promise } - */ -async function encodeEmoji(input, shortcode) { +function encodeEmoji(input, shortcode) { + let discordPreferredEncoding if (input.startsWith("mxc://")) { - return encodeCustomEmoji(input, shortcode) + // Custom emoji + let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get() + if (!row && shortcode) { + // Use the name to try to find a known emoji with the same name. + const name = shortcode.replace(/^:|:$/g, "") + row = select("emoji", ["emoji_id", "name"], {name: name}).get() + } + if (!row) { + // We don't have this emoji and there's no realistic way to just-in-time upload a new emoji somewhere. + // Sucks! + return null + } + // Cool, we got an exact or a candidate emoji. + discordPreferredEncoding = encodeURIComponent(`${row.name}:${row.emoji_id}`) } else { - return encodeDefaultEmoji(input) + // Default emoji + // https://github.com/discord/discord-api-docs/issues/2723#issuecomment-807022205 ???????????? + const encoded = encodeURIComponent(input) + const encodedTrimmed = encoded.replace(/%EF%B8%8F/g, "") + + const forceTrimmedList = [ + "%F0%9F%91%8D", // 👍 + "%F0%9F%91%8E", // 👎️ + "%E2%AD%90", // ⭐ + "%F0%9F%90%88", // 🐈 + "%E2%9D%93", // ❓ + "%F0%9F%8F%86", // 🏆️ + "%F0%9F%93%9A", // 📚️ + "%F0%9F%90%9F", // 🐟️ + ] + + discordPreferredEncoding = + ( forceTrimmedList.includes(encodedTrimmed) ? encodedTrimmed + : encodedTrimmed !== encoded && [...input].length === 2 ? encoded + : encodedTrimmed) + + console.log("add reaction from matrix:", input, encoded, encodedTrimmed, "chosen:", discordPreferredEncoding) } + return discordPreferredEncoding } module.exports.encodeEmoji = encodeEmoji diff --git a/src/m2d/converters/emoji.test.js b/src/m2d/converters/emoji.test.js deleted file mode 100644 index ad9846b..0000000 --- a/src/m2d/converters/emoji.test.js +++ /dev/null @@ -1,52 +0,0 @@ -// @ts-check - -const {test} = require("supertape") -const {encodeEmoji} = require("./emoji") - -test("emoji: valid", async t => { - t.equal(await encodeEmoji("🦄", null), "%F0%9F%A6%84") -}) - -test("emoji: freeform text", async t => { - t.equal(await encodeEmoji("ha", null), null) -}) - -test("emoji: suspicious unicode", async t => { - t.equal(await encodeEmoji("Ⓐ", null), null) -}) - -test("emoji: needs u+fe0f added", async t => { - t.equal(await encodeEmoji("☺", null), "%E2%98%BA%EF%B8%8F") -}) - -test("emoji: needs u+fe0f removed", async t => { - t.equal(await encodeEmoji("⭐️", null), "%E2%AD%90") -}) - -test("emoji: number key needs u+fe0f in the middle", async t => { - t.equal(await encodeEmoji("3⃣", null), "3%EF%B8%8F%E2%83%A3") -}) - -test("emoji: hash key needs u+fe0f in the middle", async t => { - t.equal(await encodeEmoji("#⃣", null), "%23%EF%B8%8F%E2%83%A3") -}) - -test("emoji: broken chains needs u+fe0f in the middle", async t => { - t.equal(await encodeEmoji("⛓💥", null), "%E2%9B%93%EF%B8%8F%E2%80%8D%F0%9F%92%A5") -}) - -test("emoji: balling needs u+fe0f in the middle", async t => { - t.equal(await encodeEmoji("⛹♀", null), "%E2%9B%B9%EF%B8%8F%E2%80%8D%E2%99%80%EF%B8%8F") -}) - -test("emoji: trans flag needs u+fe0f in the middle", async t => { - t.equal(await encodeEmoji("🏳⚧", null), "%F0%9F%8F%B3%EF%B8%8F%E2%80%8D%E2%9A%A7%EF%B8%8F") -}) - -test("emoji: spy needs u+fe0f in the middle", async t => { - t.equal(await encodeEmoji("🕵♀", null), "%F0%9F%95%B5%EF%B8%8F%E2%80%8D%E2%99%80%EF%B8%8F") -}) - -test("emoji: couple needs u+fe0f in the middle", async t => { - t.equal(await encodeEmoji("👩❤👩", null), "%F0%9F%91%A9%E2%80%8D%E2%9D%A4%EF%B8%8F%E2%80%8D%F0%9F%91%A9") -}) diff --git a/src/m2d/converters/emojis.txt b/src/m2d/converters/emojis.txt deleted file mode 100644 index 2ac8997..0000000 --- a/src/m2d/converters/emojis.txt +++ /dev/null @@ -1,3799 +0,0 @@ -😀 -😃 -😄 -😁 -😆 -🥹 -😅 -😂 -🤣 -🥲 -☺️ -😊 -😇 -🙂 -🙃 -😉 -😌 -😍 -🥰 -😘 -😗 -😙 -😚 -😋 -😛 -😝 -😜 -🤪 -🤨 -🧐 -🤓 -😎 -🥸 -🤩 -🥳 -😏 -😒 -😞 -😔 -😟 -😕 -🙁 -☹️ -😣 -😖 -😫 -😩 -🥺 -😢 -😭 -😤 -😠 -😡 -🤬 -🤯 -😳 -🥵 -🥶 -😶🌫️ -😱 -😨 -😰 -😥 -😓 -🤗 -🤔 -🫣 -🤭 -🫢 -🫡 -🤫 -🫠 -🤥 -😶 -🫥 -😐 -🫤 -😑 -🫨 -🙂↔️ -🙂↕️ -😬 -🙄 -😯 -😦 -😧 -😮 -😲 -🥱 -😴 -🤤 -😪 -😮💨 -😵 -😵💫 -🤐 -🥴 -🤢 -🤮 -🤧 -😷 -🤒 -🤕 -🤑 -🤠 -😈 -👿 -👹 -👺 -🤡 -💩 -👻 -💀 -☠️ -👽 -👾 -🤖 -🎃 -😺 -😸 -😹 -😻 -😼 -😽 -🙀 -😿 -😾 -🤝🏻 -🫱🏻🫲🏼 -🫱🏻🫲🏽 -🫱🏻🫲🏾 -🫱🏻🫲🏿 -🫱🏼🫲🏻 -🤝🏼 -🫱🏼🫲🏽 -🫱🏼🫲🏾 -🫱🏼🫲🏿 -🫱🏽🫲🏻 -🫱🏽🫲🏼 -🤝🏽 -🫱🏽🫲🏾 -🫱🏽🫲🏿 -🫱🏾🫲🏻 -🫱🏾🫲🏼 -🫱🏾🫲🏽 -🤝🏾 -🫱🏾🫲🏿 -🫱🏿🫲🏻 -🫱🏿🫲🏼 -🫱🏿🫲🏽 -🫱🏿🫲🏾 -🤝🏿 -🤝 -🫶🏻 -🫶🏼 -🫶🏽 -🫶🏾 -🫶🏿 -🫶 -🤲🏻 -🤲🏼 -🤲🏽 -🤲🏾 -🤲🏿 -🤲 -👐🏻 -👐🏼 -👐🏽 -👐🏾 -👐🏿 -👐 -🙌🏻 -🙌🏼 -🙌🏽 -🙌🏾 -🙌🏿 -🙌 -👏🏻 -👏🏼 -👏🏽 -👏🏾 -👏🏿 -👏 -👍🏻 -👍🏼 -👍🏽 -👍🏾 -👍🏿 -👍 -👎🏻 -👎🏼 -👎🏽 -👎🏾 -👎🏿 -👎 -👊🏻 -👊🏼 -👊🏽 -👊🏾 -👊🏿 -👊 -✊🏻 -✊🏼 -✊🏽 -✊🏾 -✊🏿 -✊ -🤛🏻 -🤛🏼 -🤛🏽 -🤛🏾 -🤛🏿 -🤛 -🤜🏻 -🤜🏼 -🤜🏽 -🤜🏾 -🤜🏿 -🤜 -🫷🏻 -🫷🏼 -🫷🏽 -🫷🏾 -🫷🏿 -🫷 -🫸🏻 -🫸🏼 -🫸🏽 -🫸🏾 -🫸🏿 -🫸 -🤞🏻 -🤞🏼 -🤞🏽 -🤞🏾 -🤞🏿 -🤞 -✌🏻 -✌🏼 -✌🏽 -✌🏾 -✌🏿 -✌️ -🫰🏻 -🫰🏼 -🫰🏽 -🫰🏾 -🫰🏿 -🫰 -🤟🏻 -🤟🏼 -🤟🏽 -🤟🏾 -🤟🏿 -🤟 -🤘🏻 -🤘🏼 -🤘🏽 -🤘🏾 -🤘🏿 -🤘 -👌🏻 -👌🏼 -👌🏽 -👌🏾 -👌🏿 -👌 -🤌🏼 -🤌🏻 -🤌🏽 -🤌🏾 -🤌🏿 -🤌 -🤏🏻 -🤏🏼 -🤏🏽 -🤏🏾 -🤏🏿 -🤏 -🫳🏻 -🫳🏼 -🫳🏽 -🫳🏾 -🫳🏿 -🫳 -🫴🏻 -🫴🏼 -🫴🏽 -🫴🏾 -🫴🏿 -🫴 -👈🏻 -👈🏼 -👈🏽 -👈🏾 -👈🏿 -👈 -👉🏻 -👉🏼 -👉🏽 -👉🏾 -👉🏿 -👉 -👆🏻 -👆🏼 -👆🏽 -👆🏾 -👆🏿 -👆 -👇🏻 -👇🏼 -👇🏽 -👇🏾 -👇🏿 -👇 -☝🏻 -☝🏼 -☝🏽 -☝🏾 -☝🏿 -☝️ -✋🏻 -✋🏼 -✋🏽 -✋🏾 -✋🏿 -✋ -🤚🏻 -🤚🏼 -🤚🏽 -🤚🏾 -🤚🏿 -🤚 -🖐🏻 -🖐🏼 -🖐🏽 -🖐🏾 -🖐🏿 -🖐️ -🖖🏻 -🖖🏼 -🖖🏽 -🖖🏾 -🖖🏿 -🖖 -👋🏻 -👋🏼 -👋🏽 -👋🏾 -👋🏿 -👋 -🤙🏻 -🤙🏼 -🤙🏽 -🤙🏾 -🤙🏿 -🤙 -🫲🏻 -🫲🏼 -🫲🏽 -🫲🏾 -🫲🏿 -🫲 -🫱🏻 -🫱🏼 -🫱🏽 -🫱🏾 -🫱🏿 -🫱 -💪🏻 -💪🏼 -💪🏽 -💪🏾 -💪🏿 -💪 -🦾 -🖕🏻 -🖕🏼 -🖕🏽 -🖕🏾 -🖕🏿 -🖕 -✍🏻 -✍🏼 -✍🏽 -✍🏾 -✍🏿 -✍️ -🙏🏻 -🙏🏼 -🙏🏽 -🙏🏾 -🙏🏿 -🙏 -🫵🏻 -🫵🏼 -🫵🏽 -🫵🏾 -🫵🏿 -🫵 -🦶🏻 -🦶🏼 -🦶🏽 -🦶🏾 -🦶🏿 -🦶 -🦵🏻 -🦵🏼 -🦵🏽 -🦵🏾 -🦵🏿 -🦵 -🦿 -💄 -💋 -👄 -🫦 -🦷 -👅 -👂🏻 -👂🏼 -👂🏽 -👂🏾 -👂🏿 -👂 -🦻🏻 -🦻🏼 -🦻🏽 -🦻🏾 -🦻🏿 -🦻 -👃🏻 -👃🏼 -👃🏽 -👃🏾 -👃🏿 -👃 -👣 -👁️ -👀 -🫀 -🫁 -🧠 -🗣️ -👤 -👥 -🫂 -👶🏻 -👶🏼 -👶🏽 -👶🏾 -👶🏿 -👶 -🧒🏻 -🧒🏼 -🧒🏽 -🧒🏾 -🧒🏿 -🧒 -👧🏻 -👧🏼 -👧🏽 -👧🏾 -👧🏿 -👧 -👦🏻 -👦🏼 -👦🏽 -👦🏾 -👦🏿 -👦 -🧑🏻 -🧑🏼 -🧑🏽 -🧑🏾 -🧑🏿 -🧑 -👩🏻 -👩🏼 -👩🏽 -👩🏾 -👩🏿 -👩 -👨🏻 -👨🏼 -👨🏽 -👨🏾 -👨🏿 -👨 -🧑🏻🦱 -🧑🏼🦱 -🧑🏽🦱 -🧑🏾🦱 -🧑🏿🦱 -🧑🦱 -👩🏻🦱 -👩🏼🦱 -👩🏽🦱 -👩🏾🦱 -👩🏿🦱 -👩🦱 -👨🏻🦱 -👨🏼🦱 -👨🏽🦱 -👨🏾🦱 -👨🏿🦱 -👨🦱 -🧑🏻🦰 -🧑🏼🦰 -🧑🏽🦰 -🧑🏾🦰 -🧑🏿🦰 -🧑🦰 -👩🏻🦰 -👩🏼🦰 -👩🏽🦰 -👩🏾🦰 -👩🏿🦰 -👩🦰 -👨🏻🦰 -👨🏼🦰 -👨🏽🦰 -👨🏾🦰 -👨🏿🦰 -👨🦰 -👱🏻 -👱🏼 -👱🏽 -👱🏾 -👱🏿 -👱 -👱🏻♀️ -👱🏼♀️ -👱🏽♀️ -👱🏾♀️ -👱🏿♀️ -👱♀️ -👱🏻♂️ -👱🏼♂️ -👱🏽♂️ -👱🏾♂️ -👱🏿♂️ -👱♂️ -🧑🏻🦳 -🧑🏼🦳 -🧑🏽🦳 -🧑🏾🦳 -🧑🏿🦳 -🧑🦳 -👩🏻🦳 -👩🏼🦳 -👩🏽🦳 -👩🏾🦳 -👩🏿🦳 -👩🦳 -👨🏻🦳 -👨🏼🦳 -👨🏽🦳 -👨🏾🦳 -👨🏿🦳 -👨🦳 -🧑🏻🦲 -🧑🏼🦲 -🧑🏽🦲 -🧑🏾🦲 -🧑🏿🦲 -🧑🦲 -👩🏻🦲 -👩🏼🦲 -👩🏽🦲 -👩🏾🦲 -👩🏿🦲 -👩🦲 -👨🏻🦲 -👨🏼🦲 -👨🏽🦲 -👨🏾🦲 -👨🏿🦲 -👨🦲 -🧔🏻 -🧔🏼 -🧔🏽 -🧔🏾 -🧔🏿 -🧔 -🧔🏻♀️ -🧔🏼♀️ -🧔🏽♀️ -🧔🏾♀️ -🧔🏿♀️ -🧔♀️ -🧔🏻♂️ -🧔🏼♂️ -🧔🏽♂️ -🧔🏾♂️ -🧔🏿♂️ -🧔♂️ -🧓🏻 -🧓🏼 -🧓🏽 -🧓🏾 -🧓🏿 -🧓 -👵🏻 -👵🏼 -👵🏽 -👵🏾 -👵🏿 -👵 -👴🏻 -👴🏼 -👴🏽 -👴🏾 -👴🏿 -👴 -👲🏻 -👲🏼 -👲🏽 -👲🏾 -👲🏿 -👲 -👳🏻 -👳🏼 -👳🏽 -👳🏾 -👳🏿 -👳 -👳🏻♀️ -👳🏼♀️ -👳🏽♀️ -👳🏾♀️ -👳🏿♀️ -👳♀️ -👳🏻♂️ -👳🏼♂️ -👳🏽♂️ -👳🏾♂️ -👳🏿♂️ -👳♂️ -🧕🏻 -🧕🏼 -🧕🏽 -🧕🏾 -🧕🏿 -🧕 -👮🏻 -👮🏼 -👮🏽 -👮🏾 -👮🏿 -👮 -👮🏻♀️ -👮🏼♀️ -👮🏽♀️ -👮🏾♀️ -👮🏿♀️ -👮♀️ -👮🏻♂️ -👮🏼♂️ -👮🏽♂️ -👮🏾♂️ -👮🏿♂️ -👮♂️ -👷🏻 -👷🏼 -👷🏽 -👷🏾 -👷🏿 -👷 -👷🏻♀️ -👷🏼♀️ -👷🏽♀️ -👷🏾♀️ -👷🏿♀️ -👷♀️ -👷🏻♂️ -👷🏼♂️ -👷🏽♂️ -👷🏾♂️ -👷🏿♂️ -👷♂️ -💂🏻 -💂🏼 -💂🏽 -💂🏾 -💂🏿 -💂 -💂🏻♀️ -💂🏼♀️ -💂🏽♀️ -💂🏾♀️ -💂🏿♀️ -💂♀️ -💂🏻♂️ -💂🏼♂️ -💂🏽♂️ -💂🏾♂️ -💂🏿♂️ -💂♂️ -🕵🏻 -🕵🏼 -🕵🏽 -🕵🏾 -🕵🏿 -🕵️ -🕵🏻♀️ -🕵🏼♀️ -🕵🏽♀️ -🕵🏾♀️ -🕵🏿♀️ -🕵️♀️ -🕵🏻♂️ -🕵🏼♂️ -🕵🏽♂️ -🕵🏾♂️ -🕵🏿♂️ -🕵️♂️ -🧑🏻⚕️ -🧑🏼⚕️ -🧑🏽⚕️ -🧑🏾⚕️ -🧑🏿⚕️ -🧑⚕️ -👩🏻⚕️ -👩🏼⚕️ -👩🏽⚕️ -👩🏾⚕️ -👩🏿⚕️ -👩⚕️ -👨🏻⚕️ -👨🏼⚕️ -👨🏽⚕️ -👨🏾⚕️ -👨🏿⚕️ -👨⚕️ -🧑🏻🌾 -🧑🏼🌾 -🧑🏽🌾 -🧑🏾🌾 -🧑🏿🌾 -🧑🌾 -👩🏻🌾 -👩🏼🌾 -👩🏽🌾 -👩🏾🌾 -👩🏿🌾 -👩🌾 -👨🏻🌾 -👨🏼🌾 -👨🏽🌾 -👨🏾🌾 -👨🏿🌾 -👨🌾 -🧑🏻🍳 -🧑🏼🍳 -🧑🏽🍳 -🧑🏾🍳 -🧑🏿🍳 -🧑🍳 -👩🏻🍳 -👩🏼🍳 -👩🏽🍳 -👩🏾🍳 -👩🏿🍳 -👩🍳 -👨🏻🍳 -👨🏼🍳 -👨🏽🍳 -👨🏾🍳 -👨🏿🍳 -👨🍳 -🧑🏻🎓 -🧑🏼🎓 -🧑🏽🎓 -🧑🏾🎓 -🧑🏿🎓 -🧑🎓 -👩🏻🎓 -👩🏼🎓 -👩🏽🎓 -👩🏾🎓 -👩🏿🎓 -👩🎓 -👨🏻🎓 -👨🏼🎓 -👨🏽🎓 -👨🏾🎓 -👨🏿🎓 -👨🎓 -🧑🏻🎤 -🧑🏼🎤 -🧑🏽🎤 -🧑🏾🎤 -🧑🏿🎤 -🧑🎤 -👩🏻🎤 -👩🏼🎤 -👩🏽🎤 -👩🏾🎤 -👩🏿🎤 -👩🎤 -👨🏻🎤 -👨🏼🎤 -👨🏽🎤 -👨🏾🎤 -👨🏿🎤 -👨🎤 -🧑🏻🏫 -🧑🏼🏫 -🧑🏽🏫 -🧑🏾🏫 -🧑🏿🏫 -🧑🏫 -👩🏻🏫 -👩🏼🏫 -👩🏽🏫 -👩🏾🏫 -👩🏿🏫 -👩🏫 -👨🏻🏫 -👨🏼🏫 -👨🏽🏫 -👨🏾🏫 -👨🏿🏫 -👨🏫 -🧑🏻🏭 -🧑🏼🏭 -🧑🏽🏭 -🧑🏾🏭 -🧑🏿🏭 -🧑🏭 -👩🏻🏭 -👩🏼🏭 -👩🏽🏭 -👩🏾🏭 -👩🏿🏭 -👩🏭 -👨🏻🏭 -👨🏼🏭 -👨🏽🏭 -👨🏾🏭 -👨🏿🏭 -👨🏭 -🧑🏻💻 -🧑🏼💻 -🧑🏽💻 -🧑🏾💻 -🧑🏿💻 -🧑💻 -👩🏻💻 -👩🏼💻 -👩🏽💻 -👩🏾💻 -👩🏿💻 -👩💻 -👨🏻💻 -👨🏼💻 -👨🏽💻 -👨🏾💻 -👨🏿💻 -👨💻 -🧑🏻💼 -🧑🏼💼 -🧑🏽💼 -🧑🏾💼 -🧑🏿💼 -🧑💼 -👩🏻💼 -👩🏼💼 -👩🏽💼 -👩🏾💼 -👩🏿💼 -👩💼 -👨🏻💼 -👨🏼💼 -👨🏽💼 -👨🏾💼 -👨🏿💼 -👨💼 -🧑🏻🔧 -🧑🏼🔧 -🧑🏽🔧 -🧑🏾🔧 -🧑🏿🔧 -🧑🔧 -👩🏻🔧 -👩🏼🔧 -👩🏽🔧 -👩🏾🔧 -👩🏿🔧 -👩🔧 -👨🏻🔧 -👨🏼🔧 -👨🏽🔧 -👨🏾🔧 -👨🏿🔧 -👨🔧 -🧑🏻🔬 -🧑🏼🔬 -🧑🏽🔬 -🧑🏾🔬 -🧑🏿🔬 -🧑🔬 -👩🏻🔬 -👩🏼🔬 -👩🏽🔬 -👩🏾🔬 -👩🏿🔬 -👩🔬 -👨🏻🔬 -👨🏼🔬 -👨🏽🔬 -👨🏾🔬 -👨🏿🔬 -👨🔬 -🧑🏻🎨 -🧑🏼🎨 -🧑🏽🎨 -🧑🏾🎨 -🧑🏿🎨 -🧑🎨 -👩🏻🎨 -👩🏼🎨 -👩🏽🎨 -👩🏾🎨 -👩🏿🎨 -👩🎨 -👨🏻🎨 -👨🏼🎨 -👨🏽🎨 -👨🏾🎨 -👨🏿🎨 -👨🎨 -🧑🏻🚒 -🧑🏼🚒 -🧑🏽🚒 -🧑🏾🚒 -🧑🏿🚒 -🧑🚒 -👩🏻🚒 -👩🏼🚒 -👩🏽🚒 -👩🏾🚒 -👩🏿🚒 -👩🚒 -👨🏻🚒 -👨🏼🚒 -👨🏽🚒 -👨🏾🚒 -👨🏿🚒 -👨🚒 -🧑🏻✈️ -🧑🏼✈️ -🧑🏽✈️ -🧑🏾✈️ -🧑🏿✈️ -🧑✈️ -👩🏻✈️ -👩🏼✈️ -👩🏽✈️ -👩🏾✈️ -👩🏿✈️ -👩✈️ -👨🏻✈️ -👨🏼✈️ -👨🏽✈️ -👨🏾✈️ -👨🏿✈️ -👨✈️ -🧑🏻🚀 -🧑🏼🚀 -🧑🏽🚀 -🧑🏾🚀 -🧑🏿🚀 -🧑🚀 -👩🏻🚀 -👩🏼🚀 -👩🏽🚀 -👩🏾🚀 -👩🏿🚀 -👩🚀 -👨🏻🚀 -👨🏼🚀 -👨🏽🚀 -👨🏾🚀 -👨🏿🚀 -👨🚀 -🧑🏻⚖️ -🧑🏼⚖️ -🧑🏽⚖️ -🧑🏾⚖️ -🧑🏿⚖️ -🧑⚖️ -👩🏻⚖️ -👩🏼⚖️ -👩🏽⚖️ -👩🏾⚖️ -👩🏿⚖️ -👩⚖️ -👨🏻⚖️ -👨🏼⚖️ -👨🏽⚖️ -👨🏾⚖️ -👨🏿⚖️ -👨⚖️ -👰🏻 -👰🏼 -👰🏽 -👰🏾 -👰🏿 -👰 -👰🏻♀️ -👰🏼♀️ -👰🏽♀️ -👰🏾♀️ -👰🏿♀️ -👰♀️ -👰🏻♂️ -👰🏼♂️ -👰🏽♂️ -👰🏾♂️ -👰🏿♂️ -👰♂️ -🤵🏻 -🤵🏼 -🤵🏽 -🤵🏾 -🤵🏿 -🤵 -🤵🏻♀️ -🤵🏼♀️ -🤵🏽♀️ -🤵🏾♀️ -🤵🏿♀️ -🤵♀️ -🤵🏻♂️ -🤵🏼♂️ -🤵🏽♂️ -🤵🏾♂️ -🤵🏿♂️ -🤵♂️ -🫅🏻 -🫅🏼 -🫅🏽 -🫅🏾 -🫅🏿 -🫅 -👸🏻 -👸🏼 -👸🏽 -👸🏾 -👸🏿 -👸 -🤴🏻 -🤴🏼 -🤴🏽 -🤴🏾 -🤴🏿 -🤴 -🦸🏻 -🦸🏼 -🦸🏽 -🦸🏾 -🦸🏿 -🦸 -🦸🏻♀️ -🦸🏼♀️ -🦸🏽♀️ -🦸🏾♀️ -🦸🏿♀️ -🦸♀️ -🦸🏻♂️ -🦸🏼♂️ -🦸🏽♂️ -🦸🏾♂️ -🦸🏿♂️ -🦸♂️ -🦹🏻 -🦹🏼 -🦹🏽 -🦹🏾 -🦹🏿 -🦹 -🦹🏻♀️ -🦹🏼♀️ -🦹🏽♀️ -🦹🏾♀️ -🦹🏿♀️ -🦹♀️ -🦹🏻♂️ -🦹🏼♂️ -🦹🏽♂️ -🦹🏾♂️ -🦹🏿♂️ -🦹♂️ -🥷🏻 -🥷🏼 -🥷🏽 -🥷🏾 -🥷🏿 -🥷 -🧑🏻🎄 -🧑🏼🎄 -🧑🏽🎄 -🧑🏾🎄 -🧑🏿🎄 -🧑🎄 -🤶🏻 -🤶🏼 -🤶🏽 -🤶🏾 -🤶🏿 -🤶 -🎅🏻 -🎅🏼 -🎅🏽 -🎅🏾 -🎅🏿 -🎅 -🧙🏻 -🧙🏼 -🧙🏽 -🧙🏾 -🧙🏿 -🧙 -🧙🏻♀️ -🧙🏼♀️ -🧙🏽♀️ -🧙🏾♀️ -🧙🏿♀️ -🧙♀️ -🧙🏻♂️ -🧙🏼♂️ -🧙🏽♂️ -🧙🏾♂️ -🧙🏿♂️ -🧙♂️ -🧝🏻 -🧝🏼 -🧝🏽 -🧝🏾 -🧝🏿 -🧝 -🧝🏻♀️ -🧝🏼♀️ -🧝🏽♀️ -🧝🏾♀️ -🧝🏿♀️ -🧝♀️ -🧝🏻♂️ -🧝🏼♂️ -🧝🏽♂️ -🧝🏾♂️ -🧝🏿♂️ -🧝♂️ -🧌 -🧛🏻 -🧛🏼 -🧛🏽 -🧛🏾 -🧛🏿 -🧛 -🧛🏻♀️ -🧛🏼♀️ -🧛🏽♀️ -🧛🏾♀️ -🧛🏿♀️ -🧛♀️ -🧛🏻♂️ -🧛🏼♂️ -🧛🏽♂️ -🧛🏾♂️ -🧛🏿♂️ -🧛♂️ -🧟 -🧟♀️ -🧟♂️ -🧞 -🧞♀️ -🧞♂️ -🧜🏻 -🧜🏼 -🧜🏽 -🧜🏾 -🧜🏿 -🧜 -🧜🏻♀️ -🧜🏼♀️ -🧜🏽♀️ -🧜🏾♀️ -🧜🏿♀️ -🧜♀️ -🧜🏻♂️ -🧜🏼♂️ -🧜🏽♂️ -🧜🏾♂️ -🧜🏿♂️ -🧜♂️ -🧚🏻 -🧚🏼 -🧚🏽 -🧚🏾 -🧚🏿 -🧚 -🧚🏻♀️ -🧚🏼♀️ -🧚🏽♀️ -🧚🏾♀️ -🧚🏿♀️ -🧚♀️ -🧚🏻♂️ -🧚🏼♂️ -🧚🏽♂️ -🧚🏾♂️ -🧚🏿♂️ -🧚♂️ -👼🏻 -👼🏼 -👼🏽 -👼🏾 -👼🏿 -👼 -🫄🏻 -🫄🏼 -🫄🏽 -🫄🏾 -🫄🏿 -🫄 -🤰🏻 -🤰🏼 -🤰🏽 -🤰🏾 -🤰🏿 -🤰 -🫃🏻 -🫃🏼 -🫃🏽 -🫃🏾 -🫃🏿 -🫃 -🤱🏻 -🤱🏼 -🤱🏽 -🤱🏾 -🤱🏿 -🤱 -🧑🏻🍼 -🧑🏼🍼 -🧑🏽🍼 -🧑🏾🍼 -🧑🏿🍼 -🧑🍼 -👩🏻🍼 -👩🏼🍼 -👩🏽🍼 -👩🏾🍼 -👩🏿🍼 -👩🍼 -👨🏻🍼 -👨🏼🍼 -👨🏽🍼 -👨🏾🍼 -👨🏿🍼 -👨🍼 -🙇🏻 -🙇🏼 -🙇🏽 -🙇🏾 -🙇🏿 -🙇 -🙇🏻♀️ -🙇🏼♀️ -🙇🏽♀️ -🙇🏾♀️ -🙇🏿♀️ -🙇♀️ -🙇🏻♂️ -🙇🏼♂️ -🙇🏽♂️ -🙇🏾♂️ -🙇🏿♂️ -🙇♂️ -💁🏻 -💁🏼 -💁🏽 -💁🏾 -💁🏿 -💁 -💁🏻♀️ -💁🏼♀️ -💁🏽♀️ -💁🏾♀️ -💁🏿♀️ -💁♀️ -💁🏻♂️ -💁🏼♂️ -💁🏽♂️ -💁🏾♂️ -💁🏿♂️ -💁♂️ -🙅🏻 -🙅🏼 -🙅🏽 -🙅🏾 -🙅🏿 -🙅 -🙅🏻♀️ -🙅🏼♀️ -🙅🏽♀️ -🙅🏾♀️ -🙅🏿♀️ -🙅♀️ -🙅🏻♂️ -🙅🏼♂️ -🙅🏽♂️ -🙅🏾♂️ -🙅🏿♂️ -🙅♂️ -🙆🏻 -🙆🏼 -🙆🏽 -🙆🏾 -🙆🏿 -🙆 -🙆🏻♀️ -🙆🏼♀️ -🙆🏽♀️ -🙆🏾♀️ -🙆🏿♀️ -🙆♀️ -🙆🏻♂️ -🙆🏼♂️ -🙆🏽♂️ -🙆🏾♂️ -🙆🏿♂️ -🙆♂️ -🙋🏻 -🙋🏼 -🙋🏽 -🙋🏾 -🙋🏿 -🙋 -🙋🏻♀️ -🙋🏼♀️ -🙋🏽♀️ -🙋🏾♀️ -🙋🏿♀️ -🙋♀️ -🙋🏻♂️ -🙋🏼♂️ -🙋🏽♂️ -🙋🏾♂️ -🙋🏿♂️ -🙋♂️ -🧏🏻 -🧏🏼 -🧏🏽 -🧏🏾 -🧏🏿 -🧏 -🧏🏻♀️ -🧏🏼♀️ -🧏🏽♀️ -🧏🏾♀️ -🧏🏿♀️ -🧏♀️ -🧏🏻♂️ -🧏🏼♂️ -🧏🏽♂️ -🧏🏾♂️ -🧏🏿♂️ -🧏♂️ -🤦🏻 -🤦🏼 -🤦🏽 -🤦🏾 -🤦🏿 -🤦 -🤦🏻♀️ -🤦🏼♀️ -🤦🏽♀️ -🤦🏾♀️ -🤦🏿♀️ -🤦♀️ -🤦🏻♂️ -🤦🏼♂️ -🤦🏽♂️ -🤦🏾♂️ -🤦🏿♂️ -🤦♂️ -🤷🏻 -🤷🏼 -🤷🏽 -🤷🏾 -🤷🏿 -🤷 -🤷🏻♀️ -🤷🏼♀️ -🤷🏽♀️ -🤷🏾♀️ -🤷🏿♀️ -🤷♀️ -🤷🏻♂️ -🤷🏼♂️ -🤷🏽♂️ -🤷🏾♂️ -🤷🏿♂️ -🤷♂️ -🙎🏻 -🙎🏼 -🙎🏽 -🙎🏾 -🙎🏿 -🙎 -🙎🏻♀️ -🙎🏼♀️ -🙎🏽♀️ -🙎🏾♀️ -🙎🏿♀️ -🙎♀️ -🙎🏻♂️ -🙎🏼♂️ -🙎🏽♂️ -🙎🏾♂️ -🙎🏿♂️ -🙎♂️ -🙍🏻 -🙍🏼 -🙍🏽 -🙍🏾 -🙍🏿 -🙍 -🙍🏻♀️ -🙍🏼♀️ -🙍🏽♀️ -🙍🏾♀️ -🙍🏿♀️ -🙍♀️ -🙍🏻♂️ -🙍🏼♂️ -🙍🏽♂️ -🙍🏾♂️ -🙍🏿♂️ -🙍♂️ -💇🏻 -💇🏼 -💇🏽 -💇🏾 -💇🏿 -💇 -💇🏻♀️ -💇🏼♀️ -💇🏽♀️ -💇🏾♀️ -💇🏿♀️ -💇♀️ -💇🏻♂️ -💇🏼♂️ -💇🏽♂️ -💇🏾♂️ -💇🏿♂️ -💇♂️ -💆🏻 -💆🏼 -💆🏽 -💆🏾 -💆🏿 -💆 -💆🏻♀️ -💆🏼♀️ -💆🏽♀️ -💆🏾♀️ -💆🏿♀️ -💆♀️ -💆🏻♂️ -💆🏼♂️ -💆🏽♂️ -💆🏾♂️ -💆🏿♂️ -💆♂️ -🧖🏻 -🧖🏼 -🧖🏽 -🧖🏾 -🧖🏿 -🧖 -🧖🏻♀️ -🧖🏼♀️ -🧖🏽♀️ -🧖🏾♀️ -🧖🏿♀️ -🧖♀️ -🧖🏻♂️ -🧖🏼♂️ -🧖🏽♂️ -🧖🏾♂️ -🧖🏿♂️ -🧖♂️ -💅🏻 -💅🏼 -💅🏽 -💅🏾 -💅🏿 -💅 -🤳🏻 -🤳🏼 -🤳🏽 -🤳🏾 -🤳🏿 -🤳 -💃🏻 -💃🏼 -💃🏽 -💃🏾 -💃🏿 -💃 -🕺🏻 -🕺🏼 -🕺🏽 -🕺🏿 -🕺🏾 -🕺 -👯 -👯♀️ -👯♂️ -🕴🏻 -🕴🏼 -🕴🏽 -🕴🏾 -🕴🏿 -🕴️ -🧑🏻🦽 -🧑🏼🦽 -🧑🏽🦽 -🧑🏾🦽 -🧑🏿🦽 -🧑🦽 -👩🏻🦽 -👩🏼🦽 -👩🏽🦽 -👩🏾🦽 -👩🏿🦽 -👩🦽 -👨🏻🦽 -👨🏼🦽 -👨🏽🦽 -👨🏾🦽 -👨🏿🦽 -👨🦽 -🧑🏻🦽➡️ -🧑🏼🦽➡️ -🧑🏽🦽➡️ -🧑🏾🦽➡️ -🧑🏿🦽➡️ -🧑🦽➡️ -👨🏼🦽➡️ -👨🏻🦽➡️ -👨🏽🦽➡️ -👨🏾🦽➡️ -👨🏿🦽➡️ -👨🦽➡️ -👩🏻🦽➡️ -👩🏼🦽➡️ -👩🏽🦽➡️ -👩🏾🦽➡️ -👩🏿🦽➡️ -👩🦽➡️ -🧑🏻🦼 -🧑🏼🦼 -🧑🏽🦼 -🧑🏾🦼 -🧑🏿🦼 -🧑🦼 -👩🏻🦼 -👩🏼🦼 -👩🏽🦼 -👩🏾🦼 -👩🏿🦼 -👩🦼 -👨🏻🦼 -👨🏼🦼 -👨🏽🦼 -👨🏾🦼 -👨🏿🦼 -👨🦼 -🧑🏻🦼➡️ -🧑🏼🦼➡️ -🧑🏽🦼➡️ -🧑🏾🦼➡️ -🧑🏿🦼➡️ -🧑🦼➡️ -👨🏻🦼➡️ -👨🏼🦼➡️ -👨🏽🦼➡️ -👨🏾🦼➡️ -👨🏿🦼➡️ -👨🦼➡️ -👩🏻🦼➡️ -👩🏼🦼➡️ -👩🏽🦼➡️ -👩🏾🦼➡️ -👩🏿🦼➡️ -👩🦼➡️ -🚶🏻 -🚶🏼 -🚶🏽 -🚶🏾 -🚶🏿 -🚶 -🚶🏻♀️ -🚶🏼♀️ -🚶🏽♀️ -🚶🏾♀️ -🚶🏿♀️ -🚶♀️ -🚶🏻♂️ -🚶🏼♂️ -🚶🏽♂️ -🚶🏾♂️ -🚶🏿♂️ -🚶♂️ -🚶🏻➡️ -🚶🏼➡️ -🚶🏽➡️ -🚶🏾➡️ -🚶🏿➡️ -🚶➡️ -🚶🏻♀️➡️ -🚶🏼♀️➡️ -🚶🏽♀️➡️ -🚶🏾♀️➡️ -🚶🏿♀️➡️ -🚶♀️➡️ -🚶🏻♂️➡️ -🚶🏼♂️➡️ -🚶🏽♂️➡️ -🚶🏾♂️➡️ -🚶🏿♂️➡️ -🚶♂️➡️ -🧑🏻🦯 -🧑🏼🦯 -🧑🏽🦯 -🧑🏾🦯 -🧑🏿🦯 -🧑🦯 -👩🏻🦯 -👩🏼🦯 -👩🏽🦯 -👩🏾🦯 -👩🏿🦯 -👩🦯 -👨🏻🦯 -👨🏼🦯 -👨🏽🦯 -👨🏾🦯 -👨🏿🦯 -👨🦯 -🧑🏻🦯➡️ -🧑🏼🦯➡️ -🧑🏽🦯➡️ -🧑🏾🦯➡️ -🧑🏿🦯➡️ -🧑🦯➡️ -👨🏻🦯➡️ -👨🏼🦯➡️ -👨🏽🦯➡️ -👨🏾🦯➡️ -👨🏿🦯➡️ -👨🦯➡️ -👩🏻🦯➡️ -👩🏼🦯➡️ -👩🏽🦯➡️ -👩🏾🦯➡️ -👩🏿🦯➡️ -👩🦯➡️ -🧎🏻 -🧎🏼 -🧎🏽 -🧎🏾 -🧎🏿 -🧎 -🧎🏻♀️ -🧎🏼♀️ -🧎🏽♀️ -🧎🏾♀️ -🧎🏿♀️ -🧎♀️ -🧎🏻♂️ -🧎🏼♂️ -🧎🏽♂️ -🧎🏾♂️ -🧎🏿♂️ -🧎♂️ -🧎🏻➡️ -🧎🏼➡️ -🧎🏽➡️ -🧎🏾➡️ -🧎🏿➡️ -🧎➡️ -🧎🏻♀️➡️ -🧎🏼♀️➡️ -🧎🏽♀️➡️ -🧎🏾♀️➡️ -🧎🏿♀️➡️ -🧎♀️➡️ -🧎🏻♂️➡️ -🧎🏼♂️➡️ -🧎🏽♂️➡️ -🧎🏾♂️➡️ -🧎🏿♂️➡️ -🧎♂️➡️ -🏃🏻 -🏃🏼 -🏃🏽 -🏃🏾 -🏃🏿 -🏃 -🏃🏻♀️ -🏃🏼♀️ -🏃🏽♀️ -🏃🏾♀️ -🏃🏿♀️ -🏃♀️ -🏃🏻♂️ -🏃🏼♂️ -🏃🏽♂️ -🏃🏾♂️ -🏃🏿♂️ -🏃♂️ -🏃🏻➡️ -🏃🏼➡️ -🏃🏽➡️ -🏃🏾➡️ -🏃🏿➡️ -🏃➡️ -🏃🏻♀️➡️ -🏃🏼♀️➡️ -🏃🏽♀️➡️ -🏃🏾♀️➡️ -🏃🏿♀️➡️ -🏃♀️➡️ -🏃🏻♂️➡️ -🏃🏼♂️➡️ -🏃🏽♂️➡️ -🏃🏾♂️➡️ -🏃🏿♂️➡️ -🏃♂️➡️ -🧍🏻 -🧍🏼 -🧍🏽 -🧍🏾 -🧍🏿 -🧍 -🧍🏻♀️ -🧍🏼♀️ -🧍🏽♀️ -🧍🏾♀️ -🧍🏿♀️ -🧍♀️ -🧍🏻♂️ -🧍🏼♂️ -🧍🏽♂️ -🧍🏾♂️ -🧍🏿♂️ -🧍♂️ -🧑🏻🤝🧑🏻 -🧑🏻🤝🧑🏼 -🧑🏻🤝🧑🏽 -🧑🏻🤝🧑🏾 -🧑🏻🤝🧑🏿 -🧑🏼🤝🧑🏻 -🧑🏼🤝🧑🏼 -🧑🏼🤝🧑🏽 -🧑🏼🤝🧑🏾 -🧑🏼🤝🧑🏿 -🧑🏽🤝🧑🏻 -🧑🏽🤝🧑🏼 -🧑🏽🤝🧑🏽 -🧑🏽🤝🧑🏾 -🧑🏽🤝🧑🏿 -🧑🏾🤝🧑🏻 -🧑🏾🤝🧑🏼 -🧑🏾🤝🧑🏽 -🧑🏾🤝🧑🏾 -🧑🏾🤝🧑🏿 -🧑🏿🤝🧑🏻 -🧑🏿🤝🧑🏼 -🧑🏿🤝🧑🏽 -🧑🏿🤝🧑🏾 -🧑🏿🤝🧑🏿 -🧑🤝🧑 -👫🏻 -👩🏻🤝👨🏼 -👩🏻🤝👨🏽 -👩🏻🤝👨🏾 -👩🏻🤝👨🏿 -👩🏼🤝👨🏻 -👫🏼 -👩🏼🤝👨🏽 -👩🏼🤝👨🏾 -👩🏼🤝👨🏿 -👩🏽🤝👨🏻 -👩🏽🤝👨🏼 -👫🏽 -👩🏽🤝👨🏾 -👩🏽🤝👨🏿 -👩🏾🤝👨🏻 -👩🏾🤝👨🏼 -👩🏾🤝👨🏽 -👫🏾 -👩🏾🤝👨🏿 -👩🏿🤝👨🏻 -👩🏿🤝👨🏼 -👩🏿🤝👨🏽 -👩🏿🤝👨🏾 -👫🏿 -👫 -👭🏻 -👩🏻🤝👩🏼 -👩🏻🤝👩🏽 -👩🏻🤝👩🏾 -👩🏻🤝👩🏿 -👩🏼🤝👩🏻 -👭🏼 -👩🏼🤝👩🏽 -👩🏼🤝👩🏾 -👩🏼🤝👩🏿 -👩🏽🤝👩🏻 -👩🏽🤝👩🏼 -👭🏽 -👩🏽🤝👩🏾 -👩🏽🤝👩🏿 -👩🏾🤝👩🏻 -👩🏾🤝👩🏼 -👩🏾🤝👩🏽 -👭🏾 -👩🏾🤝👩🏿 -👩🏿🤝👩🏻 -👩🏿🤝👩🏼 -👩🏿🤝👩🏽 -👩🏿🤝👩🏾 -👭🏿 -👭 -👬🏻 -👨🏻🤝👨🏼 -👨🏻🤝👨🏽 -👨🏻🤝👨🏾 -👨🏻🤝👨🏿 -👨🏼🤝👨🏻 -👬🏼 -👨🏼🤝👨🏽 -👨🏼🤝👨🏾 -👨🏼🤝👨🏿 -👨🏽🤝👨🏻 -👨🏽🤝👨🏼 -👬🏽 -👨🏽🤝👨🏾 -👨🏽🤝👨🏿 -👨🏾🤝👨🏻 -👨🏾🤝👨🏼 -👨🏾🤝👨🏽 -👬🏾 -👨🏾🤝👨🏿 -👨🏿🤝👨🏻 -👨🏿🤝👨🏼 -👨🏿🤝👨🏽 -👨🏿🤝👨🏾 -👬🏿 -👬 -💑🏻 -🧑🏻❤️🧑🏼 -🧑🏻❤️🧑🏽 -🧑🏻❤️🧑🏾 -🧑🏻❤️🧑🏿 -🧑🏼❤️🧑🏻 -💑🏼 -🧑🏼❤️🧑🏽 -🧑🏼❤️🧑🏾 -🧑🏼❤️🧑🏿 -🧑🏽❤️🧑🏻 -🧑🏽❤️🧑🏼 -💑🏽 -🧑🏽❤️🧑🏾 -🧑🏽❤️🧑🏿 -🧑🏾❤️🧑🏻 -🧑🏾❤️🧑🏼 -🧑🏾❤️🧑🏽 -💑🏾 -🧑🏾❤️🧑🏿 -🧑🏿❤️🧑🏻 -🧑🏿❤️🧑🏼 -🧑🏿❤️🧑🏽 -🧑🏿❤️🧑🏾 -💑🏿 -💑 -👩🏻❤️👨🏻 -👩🏻❤️👨🏼 -👩🏻❤️👨🏽 -👩🏻❤️👨🏾 -👩🏻❤️👨🏿 -👩🏼❤️👨🏻 -👩🏼❤️👨🏼 -👩🏼❤️👨🏽 -👩🏼❤️👨🏾 -👩🏼❤️👨🏿 -👩🏽❤️👨🏻 -👩🏽❤️👨🏼 -👩🏽❤️👨🏽 -👩🏽❤️👨🏾 -👩🏽❤️👨🏿 -👩🏾❤️👨🏻 -👩🏾❤️👨🏼 -👩🏾❤️👨🏽 -👩🏾❤️👨🏾 -👩🏾❤️👨🏿 -👩🏿❤️👨🏻 -👩🏿❤️👨🏼 -👩🏿❤️👨🏽 -👩🏿❤️👨🏾 -👩🏿❤️👨🏿 -👩❤️👨 -👩🏻❤️👩🏻 -👩🏻❤️👩🏼 -👩🏻❤️👩🏽 -👩🏻❤️👩🏾 -👩🏻❤️👩🏿 -👩🏼❤️👩🏻 -👩🏼❤️👩🏼 -👩🏼❤️👩🏽 -👩🏼❤️👩🏾 -👩🏼❤️👩🏿 -👩🏽❤️👩🏻 -👩🏽❤️👩🏼 -👩🏽❤️👩🏽 -👩🏽❤️👩🏾 -👩🏽❤️👩🏿 -👩🏾❤️👩🏻 -👩🏾❤️👩🏼 -👩🏾❤️👩🏽 -👩🏾❤️👩🏾 -👩🏾❤️👩🏿 -👩🏿❤️👩🏻 -👩🏿❤️👩🏼 -👩🏿❤️👩🏽 -👩🏿❤️👩🏾 -👩🏿❤️👩🏿 -👩❤️👩 -👨🏻❤️👨🏻 -👨🏻❤️👨🏼 -👨🏻❤️👨🏽 -👨🏻❤️👨🏾 -👨🏻❤️👨🏿 -👨🏼❤️👨🏻 -👨🏼❤️👨🏼 -👨🏼❤️👨🏽 -👨🏼❤️👨🏾 -👨🏼❤️👨🏿 -👨🏽❤️👨🏻 -👨🏽❤️👨🏼 -👨🏽❤️👨🏽 -👨🏽❤️👨🏾 -👨🏽❤️👨🏿 -👨🏾❤️👨🏻 -👨🏾❤️👨🏼 -👨🏾❤️👨🏽 -👨🏾❤️👨🏾 -👨🏾❤️👨🏿 -👨🏿❤️👨🏻 -👨🏿❤️👨🏼 -👨🏿❤️👨🏽 -👨🏿❤️👨🏾 -👨🏿❤️👨🏿 -👨❤️👨 -💏🏻 -🧑🏻❤️💋🧑🏼 -🧑🏻❤️💋🧑🏽 -🧑🏻❤️💋🧑🏾 -🧑🏻❤️💋🧑🏿 -🧑🏼❤️💋🧑🏻 -💏🏼 -🧑🏼❤️💋🧑🏽 -🧑🏼❤️💋🧑🏾 -🧑🏼❤️💋🧑🏿 -🧑🏽❤️💋🧑🏻 -🧑🏽❤️💋🧑🏼 -💏🏽 -🧑🏽❤️💋🧑🏾 -🧑🏽❤️💋🧑🏿 -🧑🏾❤️💋🧑🏻 -🧑🏾❤️💋🧑🏼 -🧑🏾❤️💋🧑🏽 -💏🏾 -🧑🏾❤️💋🧑🏿 -🧑🏿❤️💋🧑🏻 -🧑🏿❤️💋🧑🏼 -🧑🏿❤️💋🧑🏽 -🧑🏿❤️💋🧑🏾 -💏🏿 -💏 -👩🏻❤️💋👨🏻 -👩🏻❤️💋👨🏼 -👩🏻❤️💋👨🏽 -👩🏻❤️💋👨🏾 -👩🏻❤️💋👨🏿 -👩🏼❤️💋👨🏻 -👩🏼❤️💋👨🏼 -👩🏼❤️💋👨🏽 -👩🏼❤️💋👨🏾 -👩🏼❤️💋👨🏿 -👩🏽❤️💋👨🏻 -👩🏽❤️💋👨🏼 -👩🏽❤️💋👨🏽 -👩🏽❤️💋👨🏾 -👩🏽❤️💋👨🏿 -👩🏾❤️💋👨🏻 -👩🏾❤️💋👨🏼 -👩🏾❤️💋👨🏽 -👩🏾❤️💋👨🏾 -👩🏾❤️💋👨🏿 -👩🏿❤️💋👨🏻 -👩🏿❤️💋👨🏼 -👩🏿❤️💋👨🏽 -👩🏿❤️💋👨🏾 -👩🏿❤️💋👨🏿 -👩❤️💋👨 -👩🏻❤️💋👩🏻 -👩🏻❤️💋👩🏼 -👩🏻❤️💋👩🏽 -👩🏻❤️💋👩🏾 -👩🏻❤️💋👩🏿 -👩🏼❤️💋👩🏻 -👩🏼❤️💋👩🏼 -👩🏼❤️💋👩🏽 -👩🏼❤️💋👩🏾 -👩🏼❤️💋👩🏿 -👩🏽❤️💋👩🏻 -👩🏽❤️💋👩🏼 -👩🏽❤️💋👩🏽 -👩🏽❤️💋👩🏾 -👩🏽❤️💋👩🏿 -👩🏾❤️💋👩🏻 -👩🏾❤️💋👩🏼 -👩🏾❤️💋👩🏽 -👩🏾❤️💋👩🏾 -👩🏾❤️💋👩🏿 -👩🏿❤️💋👩🏻 -👩🏿❤️💋👩🏼 -👩🏿❤️💋👩🏽 -👩🏿❤️💋👩🏾 -👩🏿❤️💋👩🏿 -👩❤️💋👩 -👨🏻❤️💋👨🏻 -👨🏻❤️💋👨🏼 -👨🏻❤️💋👨🏽 -👨🏻❤️💋👨🏾 -👨🏻❤️💋👨🏿 -👨🏼❤️💋👨🏻 -👨🏼❤️💋👨🏼 -👨🏼❤️💋👨🏽 -👨🏼❤️💋👨🏾 -👨🏼❤️💋👨🏿 -👨🏽❤️💋👨🏻 -👨🏽❤️💋👨🏼 -👨🏽❤️💋👨🏽 -👨🏽❤️💋👨🏾 -👨🏽❤️💋👨🏿 -👨🏾❤️💋👨🏻 -👨🏾❤️💋👨🏼 -👨🏾❤️💋👨🏽 -👨🏾❤️💋👨🏾 -👨🏾❤️💋👨🏿 -👨🏿❤️💋👨🏻 -👨🏿❤️💋👨🏼 -👨🏿❤️💋👨🏽 -👨🏿❤️💋👨🏾 -👨🏿❤️💋👨🏿 -👨❤️💋👨 -🧑🧑🧒🧒 -🧑🧑🧒 -🧑🧒🧒 -🧑🧒 -👪 -👨👩👦 -👨👩👧 -👨👩👧👦 -👨👩👦👦 -👨👩👧👧 -👩👩👦 -👩👩👧 -👩👩👧👦 -👩👩👦👦 -👩👩👧👧 -👨👨👦 -👨👨👧 -👨👨👧👦 -👨👨👦👦 -👨👨👧👧 -👩👦 -👩👧 -👩👧👦 -👩👦👦 -👩👧👧 -👨👦 -👨👧 -👨👧👦 -👨👦👦 -👨👧👧 -🪢 -🧶 -🧵 -🪡 -🧥 -🥼 -🦺 -👚 -👕 -👖 -🩲 -🩳 -👔 -👗 -👙 -🩱 -👘 -🥻 -🩴 -🥿 -👠 -👡 -👢 -👞 -👟 -🥾 -🧦 -🧤 -🧣 -🎩 -🧢 -👒 -🎓 -⛑️ -🪖 -👑 -💍 -👝 -👛 -👜 -💼 -🎒 -🧳 -👓 -🕶️ -🥽 -🌂 -🐶 -🐱 -🐭 -🐹 -🐰 -🦊 -🐻 -🐼 -🐻❄️ -🐨 -🐯 -🦁 -🐮 -🐷 -🐽 -🐸 -🐵 -🙈 -🙉 -🙊 -🐒 -🐔 -🐧 -🐦 -🐤 -🐣 -🐥 -🪿 -🦆 -🐦⬛ -🦅 -🦉 -🦇 -🐺 -🐗 -🐴 -🦄 -🫎 -🐝 -🪱 -🐛 -🦋 -🐌 -🐞 -🐜 -🪰 -🪲 -🪳 -🦟 -🦗 -🕷️ -🕸️ -🦂 -🐢 -🐍 -🦎 -🦖 -🦕 -🐙 -🦑 -🪼 -🦐 -🦞 -🦀 -🐡 -🐠 -🐟 -🐬 -🐳 -🐋 -🦈 -🦭 -🐊 -🐅 -🐆 -🦓 -🦍 -🦧 -🦣 -🐘 -🦛 -🦏 -🐪 -🐫 -🦒 -🦘 -🦬 -🐃 -🐂 -🐄 -🫏 -🐎 -🐖 -🐏 -🐑 -🦙 -🐐 -🦌 -🐕 -🐩 -🦮 -🐕🦺 -🐈 -🐈⬛ -🪶 -🪽 -🐓 -🦃 -🦤 -🦚 -🦜 -🦢 -🦩 -🕊️ -🐇 -🦝 -🦨 -🦡 -🦫 -🦦 -🦥 -🐁 -🐀 -🐿️ -🦔 -🐾 -🐉 -🐲 -🐦🔥 -🌵 -🎄 -🌲 -🌳 -🌴 -🪵 -🌱 -🌿 -☘️ -🍀 -🎍 -🪴 -🎋 -🍃 -🍂 -🍁 -🪺 -🪹 -🍄 -🍄🟫 -🐚 -🪸 -🪨 -🌾 -💐 -🌷 -🌹 -🥀 -🪻 -🪷 -🌺 -🌸 -🌼 -🌻 -🌞 -🌝 -🌛 -🌜 -🌚 -🌕 -🌖 -🌗 -🌘 -🌑 -🌒 -🌓 -🌔 -🌙 -🌎 -🌍 -🌏 -🪐 -💫 -⭐ -🌟 -✨ -⚡ -☄️ -💥 -🔥 -🌪️ -🌈 -☀️ -🌤️ -⛅ -🌥️ -☁️ -🌦️ -🌧️ -⛈️ -🌩️ -🌨️ -❄️ -☃️ -⛄ -🌬️ -💨 -💧 -💦 -🫧 -☔ -☂️ -🌊 -🌫️ -🍏 -🍎 -🍐 -🍊 -🍋 -🍋🟩 -🍌 -🍉 -🍇 -🍓 -🫐 -🍈 -🍒 -🍑 -🥭 -🍍 -🥥 -🥝 -🍅 -🍆 -🥑 -🫛 -🥦 -🥬 -🥒 -🌶️ -🫑 -🌽 -🥕 -🫒 -🧄 -🧅 -🥔 -🍠 -🫚 -🥐 -🥯 -🍞 -🥖 -🥨 -🧀 -🥚 -🍳 -🧈 -🥞 -🧇 -🥓 -🥩 -🍗 -🍖 -🦴 -🌭 -🍔 -🍟 -🍕 -🫓 -🥪 -🥙 -🧆 -🌮 -🌯 -🫔 -🥗 -🥘 -🫕 -🥫 -🫙 -🍝 -🍜 -🍲 -🍛 -🍣 -🍱 -🥟 -🦪 -🍤 -🍙 -🍚 -🍘 -🍥 -🥠 -🥮 -🍢 -🍡 -🍧 -🍨 -🍦 -🥧 -🧁 -🍰 -🎂 -🍮 -🍭 -🍬 -🍫 -🍿 -🍩 -🍪 -🌰 -🥜 -🫘 -🍯 -🥛 -🫗 -🍼 -🫖 -☕ -🍵 -🧉 -🧃 -🥤 -🧋 -🍶 -🍺 -🍻 -🥂 -🍷 -🥃 -🍸 -🍹 -🍾 -🧊 -🥄 -🍴 -🍽️ -🥣 -🥡 -🥢 -🧂 -⚽ -🏀 -🏈 -⚾ -🥎 -🎾 -🏐 -🏉 -🥏 -🎱 -🪀 -🏓 -🏸 -🏒 -🏑 -🥍 -🏏 -🪃 -🥅 -⛳ -🪁 -🛝 -🏹 -🎣 -🤿 -🥊 -🥋 -🎽 -🛹 -🛼 -🛷 -⛸️ -🥌 -🎿 -⛷️ -🏂🏻 -🏂🏼 -🏂🏽 -🏂🏾 -🏂🏿 -🏂 -🪂 -🏋🏻 -🏋🏼 -🏋🏽 -🏋🏾 -🏋🏿 -🏋️ -🏋🏻♀️ -🏋🏼♀️ -🏋🏽♀️ -🏋🏾♀️ -🏋🏿♀️ -🏋️♀️ -🏋🏻♂️ -🏋🏼♂️ -🏋🏽♂️ -🏋🏾♂️ -🏋🏿♂️ -🏋️♂️ -🤼 -🤼♀️ -🤼♂️ -🤸🏻 -🤸🏼 -🤸🏽 -🤸🏾 -🤸🏿 -🤸 -🤸🏻♀️ -🤸🏼♀️ -🤸🏽♀️ -🤸🏾♀️ -🤸🏿♀️ -🤸♀️ -🤸🏻♂️ -🤸🏼♂️ -🤸🏽♂️ -🤸🏾♂️ -🤸🏿♂️ -🤸♂️ -⛹🏻 -⛹🏼 -⛹🏽 -⛹🏾 -⛹🏿 -⛹️ -⛹🏻♀️ -⛹🏼♀️ -⛹🏽♀️ -⛹🏾♀️ -⛹🏿♀️ -⛹️♀️ -⛹🏻♂️ -⛹🏼♂️ -⛹🏽♂️ -⛹🏾♂️ -⛹🏿♂️ -⛹️♂️ -🤺 -🤾🏻 -🤾🏼 -🤾🏽 -🤾🏾 -🤾🏿 -🤾 -🤾🏻♀️ -🤾🏼♀️ -🤾🏽♀️ -🤾🏾♀️ -🤾🏿♀️ -🤾♀️ -🤾🏻♂️ -🤾🏼♂️ -🤾🏽♂️ -🤾🏾♂️ -🤾🏿♂️ -🤾♂️ -🏌🏻 -🏌🏼 -🏌🏽 -🏌🏾 -🏌🏿 -🏌️ -🏌🏻♀️ -🏌🏼♀️ -🏌🏽♀️ -🏌🏾♀️ -🏌🏿♀️ -🏌️♀️ -🏌🏻♂️ -🏌🏼♂️ -🏌🏽♂️ -🏌🏾♂️ -🏌🏿♂️ -🏌️♂️ -🏇🏻 -🏇🏼 -🏇🏽 -🏇🏾 -🏇🏿 -🏇 -🧘🏻 -🧘🏼 -🧘🏽 -🧘🏾 -🧘🏿 -🧘 -🧘🏻♀️ -🧘🏼♀️ -🧘🏽♀️ -🧘🏾♀️ -🧘🏿♀️ -🧘♀️ -🧘🏻♂️ -🧘🏼♂️ -🧘🏽♂️ -🧘🏾♂️ -🧘🏿♂️ -🧘♂️ -🏄🏻 -🏄🏼 -🏄🏽 -🏄🏾 -🏄🏿 -🏄 -🏄🏻♀️ -🏄🏼♀️ -🏄🏽♀️ -🏄🏾♀️ -🏄🏿♀️ -🏄♀️ -🏄🏻♂️ -🏄🏼♂️ -🏄🏽♂️ -🏄🏾♂️ -🏄🏿♂️ -🏄♂️ -🏊🏻 -🏊🏼 -🏊🏽 -🏊🏾 -🏊🏿 -🏊 -🏊🏻♀️ -🏊🏼♀️ -🏊🏽♀️ -🏊🏾♀️ -🏊🏿♀️ -🏊♀️ -🏊🏻♂️ -🏊🏼♂️ -🏊🏽♂️ -🏊🏾♂️ -🏊🏿♂️ -🏊♂️ -🤽🏻 -🤽🏼 -🤽🏽 -🤽🏾 -🤽🏿 -🤽 -🤽🏻♀️ -🤽🏼♀️ -🤽🏽♀️ -🤽🏾♀️ -🤽🏿♀️ -🤽♀️ -🤽🏻♂️ -🤽🏼♂️ -🤽🏽♂️ -🤽🏾♂️ -🤽🏿♂️ -🤽♂️ -🚣🏻 -🚣🏼 -🚣🏽 -🚣🏾 -🚣🏿 -🚣 -🚣🏻♀️ -🚣🏼♀️ -🚣🏽♀️ -🚣🏾♀️ -🚣🏿♀️ -🚣♀️ -🚣🏻♂️ -🚣🏼♂️ -🚣🏽♂️ -🚣🏾♂️ -🚣🏿♂️ -🚣♂️ -🧗🏻 -🧗🏼 -🧗🏽 -🧗🏾 -🧗🏿 -🧗 -🧗🏻♀️ -🧗🏼♀️ -🧗🏽♀️ -🧗🏾♀️ -🧗🏿♀️ -🧗♀️ -🧗🏻♂️ -🧗🏼♂️ -🧗🏽♂️ -🧗🏾♂️ -🧗🏿♂️ -🧗♂️ -🚵🏻 -🚵🏼 -🚵🏽 -🚵🏾 -🚵🏿 -🚵 -🚵🏻♀️ -🚵🏼♀️ -🚵🏽♀️ -🚵🏾♀️ -🚵🏿♀️ -🚵♀️ -🚵🏻♂️ -🚵🏼♂️ -🚵🏽♂️ -🚵🏾♂️ -🚵🏿♂️ -🚵♂️ -🚴🏻 -🚴🏼 -🚴🏽 -🚴🏾 -🚴🏿 -🚴 -🚴🏻♀️ -🚴🏼♀️ -🚴🏽♀️ -🚴🏾♀️ -🚴🏿♀️ -🚴♀️ -🚴🏻♂️ -🚴🏼♂️ -🚴🏽♂️ -🚴🏾♂️ -🚴🏿♂️ -🚴♂️ -🏆 -🥇 -🥈 -🥉 -🏅 -🎖️ -🏵️ -🎗️ -🎫 -🎟️ -🎪 -🤹🏻 -🤹🏼 -🤹🏽 -🤹🏾 -🤹🏿 -🤹 -🤹🏻♀️ -🤹🏼♀️ -🤹🏽♀️ -🤹🏾♀️ -🤹🏿♀️ -🤹♀️ -🤹🏻♂️ -🤹🏼♂️ -🤹🏽♂️ -🤹🏾♂️ -🤹🏿♂️ -🤹♂️ -🎭 -🩰 -🎨 -🎬 -🎤 -🎧 -🎼 -🎹 -🪇 -🥁 -🪘 -🎷 -🎺 -🪗 -🎸 -🪕 -🎻 -🪈 -🎲 -♟️ -🎯 -🎳 -🎮 -🎰 -🧩 -🚗 -🚕 -🚙 -🛻 -🚐 -🚌 -🚎 -🏎️ -🚓 -🚑 -🚒 -🚚 -🚛 -🚜 -🦯 -🦽 -🦼 -🩼 -🛴 -🚲 -🛵 -🏍️ -🛺 -🛞 -🚨 -🚔 -🚍 -🚘 -🚖 -🚡 -🚠 -🚟 -🚃 -🚋 -🚞 -🚝 -🚄 -🚅 -🚈 -🚂 -🚆 -🚇 -🚊 -🚉 -✈️ -🛫 -🛬 -🛩️ -💺 -🛰️ -🚀 -🛸 -🚁 -🛶 -⛵ -🚤 -🛥️ -🛳️ -⛴️ -🚢 -🛟 -⚓ -🪝 -⛽ -🚧 -🚦 -🚥 -🚏 -🗺️ -🗿 -🗽 -🗼 -🏰 -🏯 -🏟️ -🎡 -🎢 -🎠 -⛲ -⛱️ -🏖️ -🏝️ -🏜️ -🌋 -⛰️ -🏔️ -🗻 -🏕️ -⛺ -🏠 -🏡 -🏘️ -🏚️ -🛖 -🏗️ -🏭 -🏢 -🏬 -🏣 -🏤 -🏥 -🏦 -🏨 -🏪 -🏫 -🏩 -💒 -🏛️ -⛪ -🕌 -🕍 -🛕 -🕋 -⛩️ -🛤️ -🛣️ -🗾 -🎑 -🏞️ -🌅 -🌄 -🌠 -🎇 -🎆 -🌇 -🌆 -🏙️ -🌃 -🌌 -🌉 -🌁 -⌚ -📱 -📲 -💻 -⌨️ -🖥️ -🖨️ -🖱️ -🖲️ -🕹️ -🗜️ -💽 -💾 -💿 -📀 -📼 -📷 -📸 -📹 -🎥 -📽️ -🎞️ -📞 -☎️ -📟 -📠 -📺 -📻 -🎙️ -🎚️ -🎛️ -🧭 -⏱️ -⏲️ -⏰ -🕰️ -⌛ -⏳ -📡 -🔋 -🪫 -🔌 -💡 -🔦 -🕯️ -🪔 -🧯 -🛢️ -💸 -💵 -💴 -💶 -💷 -🪙 -💰 -💳 -🪪 -💎 -⚖️ -🪜 -🧰 -🪛 -🔧 -🔨 -⚒️ -🛠️ -⛏️ -🪚 -🔩 -⚙️ -🪤 -🧱 -⛓️ -🔗 -⛓️💥 -🧲 -🔫 -💣 -🧨 -🪓 -🔪 -🗡️ -⚔️ -🛡️ -🚬 -⚰️ -🪦 -⚱️ -🏺 -🔮 -📿 -🧿 -🪬 -💈 -⚗️ -🔭 -🔬 -🕳️ -🩻 -🩹 -🩺 -💊 -💉 -🩸 -🧬 -🦠 -🧫 -🧪 -🌡️ -🧹 -🪠 -🧺 -🧻 -🚽 -🚰 -🚿 -🛁 -🛀🏻 -🛀🏼 -🛀🏽 -🛀🏾 -🛀🏿 -🛀 -🧼 -🪥 -🪒 -🪮 -🧽 -🪣 -🧴 -🛎️ -🔑 -🗝️ -🚪 -🪑 -🛋️ -🛏️ -🛌🏻 -🛌🏼 -🛌🏽 -🛌🏾 -🛌🏿 -🛌 -🧸 -🪆 -🖼️ -🪞 -🪟 -🛍️ -🛒 -🎁 -🎈 -🎏 -🎀 -🪄 -🪅 -🎊 -🎉 -🎎 -🪭 -🏮 -🎐 -🪩 -🧧 -✉️ -📩 -📨 -📧 -💌 -📥 -📤 -📦 -🏷️ -🪧 -📪 -📫 -📬 -📭 -📮 -📯 -📜 -📃 -📄 -📑 -🧾 -📊 -📈 -📉 -🗒️ -🗓️ -📆 -📅 -🗑️ -📇 -🗃️ -🗳️ -🗄️ -📋 -📁 -📂 -🗂️ -🗞️ -📰 -📓 -📔 -📒 -📕 -📗 -📘 -📙 -📚 -📖 -🔖 -🧷 -📎 -🖇️ -📐 -📏 -🧮 -📌 -📍 -✂️ -🖊️ -🖋️ -✒️ -🖌️ -🖍️ -📝 -✏️ -🔍 -🔎 -🔏 -🔐 -🔒 -🔓 -🩷 -❤️ -🧡 -💛 -💚 -🩵 -💙 -💜 -🖤 -🩶 -🤍 -🤎 -💔 -❣️ -💕 -💞 -💓 -💗 -💖 -💘 -💝 -❤️🩹 -❤️🔥 -💟 -☮️ -✝️ -☪️ -🕉️ -☸️ -🪯 -✡️ -🔯 -🕎 -☯️ -☦️ -🛐 -⛎ -♈ -♉ -♊ -♋ -♌ -♍ -♎ -♏ -♐ -♑ -♒ -♓ -🆔 -⚛️ -🉑 -☢️ -☣️ -📴 -📳 -🈶 -🈚 -🈸 -🈺 -🈷️ -✴️ -🆚 -💮 -🉐 -㊙️ -㊗️ -🈴 -🈵 -🈹 -🈲 -🅰️ -🅱️ -🆎 -🆑 -🅾️ -🆘 -❌ -⭕ -🛑 -⛔ -📛 -🚫 -💯 -💢 -♨️ -🚷 -🚯 -🚳 -🚱 -🔞 -📵 -🚭 -❗ -❕ -❓ -❔ -‼️ -⁉️ -🔅 -🔆 -〽️ -⚠️ -🚸 -🔱 -⚜️ -🔰 -♻️ -✅ -🈯 -💹 -❇️ -✳️ -❎ -🌐 -💠 -Ⓜ️ -🌀 -💤 -🏧 -🚾 -♿ -🅿️ -🛗 -🈳 -🈂️ -🛂 -🛃 -🛄 -🛅 -🛜 -🚹 -🚺 -🚼 -🚻 -🚮 -🎦 -📶 -🈁 -🔣 -ℹ️ -🔤 -🔡 -🔠 -🆖 -🆗 -🆙 -🆒 -🆕 -🆓 -0️⃣ -1️⃣ -2️⃣ -3️⃣ -4️⃣ -5️⃣ -6️⃣ -7️⃣ -8️⃣ -9️⃣ -🔟 -🔢 -#️⃣ -*️⃣ -⏏️ -▶️ -⏸️ -⏯️ -⏹️ -⏺️ -⏭️ -⏮️ -⏩ -⏪ -⏫ -⏬ -◀️ -🔼 -🔽 -➡️ -⬅️ -⬆️ -⬇️ -↗️ -↘️ -↙️ -↖️ -↕️ -↔️ -↪️ -↩️ -⤴️ -⤵️ -🔀 -🔁 -🔂 -🔄 -🔃 -🎵 -🎶 -➕ -➖ -➗ -✖️ -🟰 -♾️ -💲 -💱 -™️ -©️ -®️ -〰️ -➰ -➿ -🔚 -🔙 -🔛 -🔝 -🔜 -✔️ -☑️ -🔘 -⚪ -⚫ -🔴 -🔵 -🟤 -🟣 -🟢 -🟡 -🟠 -🔺 -🔻 -🔸 -🔹 -🔶 -🔷 -🔳 -🔲 -▪️ -▫️ -◾ -◽ -◼️ -◻️ -⬛ -⬜ -🟧 -🟦 -🟥 -🟫 -🟪 -🟩 -🟨 -🔈 -🔇 -🔉 -🔊 -🔔 -🔕 -📣 -📢 -🗨️ -👁🗨 -💬 -💭 -🗯️ -♠️ -♣️ -♥️ -♦️ -🃏 -🎴 -🀄 -🕐 -🕑 -🕒 -🕓 -🕔 -🕕 -🕖 -🕗 -🕘 -🕙 -🕚 -🕛 -🕜 -🕝 -🕞 -🕟 -🕠 -🕡 -🕢 -🕣 -🕤 -🕥 -🕦 -🕧 -♀️ -♂️ -⚧ -⚕️ -🇿 -🇾 -🇽 -🇼 -🇻 -🇺 -🇹 -🇸 -🇷 -🇶 -🇵 -🇴 -🇳 -🇲 -🇱 -🇰 -🇯 -🇮 -🇭 -🇬 -🇫 -🇪 -🇩 -🇨 -🇧 -🇦 -🏳️ -🏴 -🏴☠️ -🏁 -🚩 -🏳️🌈 -🏳️⚧️ -🇺🇳 -🇦🇫 -🇦🇽 -🇦🇱 -🇩🇿 -🇦🇸 -🇦🇩 -🇦🇴 -🇦🇮 -🇦🇶 -🇦🇬 -🇦🇷 -🇦🇲 -🇦🇼 -🇦🇺 -🇦🇹 -🇦🇿 -🇧🇸 -🇧🇭 -🇧🇩 -🇧🇧 -🇧🇾 -🇧🇪 -🇧🇿 -🇧🇯 -🇧🇲 -🇧🇹 -🇧🇴 -🇧🇦 -🇧🇼 -🇧🇷 -🇮🇴 -🇻🇬 -🇧🇳 -🇧🇬 -🇧🇫 -🇧🇮 -🇰🇭 -🇨🇲 -🇨🇦 -🇮🇨 -🇨🇻 -🇧🇶 -🇰🇾 -🇨🇫 -🇹🇩 -🇨🇱 -🇨🇳 -🇨🇽 -🇨🇨 -🇨🇴 -🇰🇲 -🇨🇬 -🇨🇩 -🇨🇰 -🇨🇷 -🇨🇮 -🇭🇷 -🇨🇺 -🇨🇼 -🇨🇾 -🇨🇿 -🇩🇰 -🇩🇯 -🇩🇲 -🇩🇴 -🇪🇨 -🇪🇬 -🇸🇻 -🇬🇶 -🇪🇷 -🇪🇪 -🇪🇹 -🇪🇺 -🇫🇰 -🇫🇴 -🇫🇯 -🇫🇮 -🇫🇷 -🇬🇫 -🇵🇫 -🇹🇫 -🇬🇦 -🇬🇲 -🇬🇪 -🇩🇪 -🇬🇭 -🇬🇮 -🇬🇷 -🇬🇱 -🇬🇩 -🇬🇵 -🇬🇺 -🇬🇹 -🇬🇬 -🇬🇳 -🇬🇼 -🇬🇾 -🇭🇹 -🇭🇳 -🇭🇰 -🇭🇺 -🇮🇸 -🇮🇳 -🇮🇩 -🇮🇷 -🇮🇶 -🇮🇪 -🇮🇲 -🇮🇱 -🇮🇹 -🇯🇲 -🇯🇵 -🎌 -🇯🇪 -🇯🇴 -🇰🇿 -🇰🇪 -🇰🇮 -🇽🇰 -🇰🇼 -🇰🇬 -🇱🇦 -🇱🇻 -🇱🇧 -🇱🇸 -🇱🇷 -🇱🇾 -🇱🇮 -🇱🇹 -🇱🇺 -🇲🇴 -🇲🇰 -🇲🇬 -🇲🇼 -🇲🇾 -🇲🇻 -🇲🇱 -🇲🇹 -🇲🇭 -🇲🇶 -🇲🇷 -🇲🇺 -🇾🇹 -🇲🇽 -🇫🇲 -🇲🇩 -🇲🇨 -🇲🇳 -🇲🇪 -🇲🇸 -🇲🇦 -🇲🇿 -🇲🇲 -🇳🇦 -🇳🇷 -🇳🇵 -🇳🇱 -🇳🇨 -🇳🇿 -🇳🇮 -🇳🇪 -🇳🇬 -🇳🇺 -🇳🇫 -🇰🇵 -🇲🇵 -🇳🇴 -🇴🇲 -🇵🇰 -🇵🇼 -🇵🇸 -🇵🇦 -🇵🇬 -🇵🇾 -🇵🇪 -🇵🇭 -🇵🇳 -🇵🇱 -🇵🇹 -🇵🇷 -🇶🇦 -🇷🇪 -🇷🇴 -🇷🇺 -🇷🇼 -🇼🇸 -🇸🇲 -🇸🇹 -🇸🇦 -🇸🇳 -🇷🇸 -🇸🇨 -🇸🇱 -🇸🇬 -🇸🇽 -🇸🇰 -🇸🇮 -🇬🇸 -🇸🇧 -🇸🇴 -🇿🇦 -🇰🇷 -🇸🇸 -🇪🇸 -🇱🇰 -🇧🇱 -🇸🇭 -🇰🇳 -🇱🇨 -🇵🇲 -🇻🇨 -🇸🇩 -🇸🇷 -🇸🇿 -🇸🇪 -🇨🇭 -🇸🇾 -🇹🇼 -🇹🇯 -🇹🇿 -🇹🇭 -🇹🇱 -🇹🇬 -🇹🇰 -🇹🇴 -🇹🇹 -🇹🇳 -🇹🇷 -🇹🇲 -🇹🇨 -🇻🇮 -🇹🇻 -🇺🇬 -🇺🇦 -🇦🇪 -🇬🇧 -🏴 -🏴 -🏴 -🇺🇸 -🇺🇾 -🇺🇿 -🇻🇺 -🇻🇦 -🇻🇪 -🇻🇳 -🇼🇫 -🇪🇭 -🇾🇪 -🇿🇲 -🇿🇼 -🇦🇨 -🇧🇻 -🇨🇵 -🇪🇦 -🇩🇬 -🇭🇲 -🇲🇫 -🇸🇯 -🇹🇦 -🇺🇲 diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 3cf08cf..b52717d 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -2,7 +2,7 @@ const Ty = require("../../types") const DiscordTypes = require("discord-api-types/v10") -const stream = require("stream") +const {Readable} = require("stream") const chunk = require("chunk-text") const TurndownService = require("@cloudrac3r/turndown") const domino = require("domino") @@ -19,8 +19,6 @@ const dUtils = sync.require("../../discord/utils") const file = sync.require("../../matrix/file") /** @type {import("./emoji-sheet")} */ const emojiSheet = sync.require("./emoji-sheet") -/** @type {import("../actions/setup-emojis")} */ -const setupEmojis = sync.require("../actions/setup-emojis") /** @type {[RegExp, string][]} */ const markdownEscapes = [ @@ -156,27 +154,6 @@ turndownService.addRule("listItem", { } }) -turndownService.addRule("table", { - filter: "table", - replacement: function (content, node, options) { - const trs = node.querySelectorAll("tr").cache - /** @type {{text: string, tag: string}[][]} */ - const tableText = trs.map(tr => [...tr.querySelectorAll("th, td")].map(cell => ({text: cell.textContent, tag: cell.tagName}))) - const tableTextByColumn = tableText[0].map((col, i) => tableText.map(row => row[i])) - const columnWidths = tableTextByColumn.map(col => Math.max(...col.map(cell => cell.text.length))) - const resultRows = tableText.map((row, rowIndex) => - row.map((cell, colIndex) => - cell.text.padEnd(columnWidths[colIndex]) - ).join(" ") - ) - const tableHasHeader = tableText[0].slice(1).some(cell => cell.tag === "TH") - if (tableHasHeader) { - resultRows.splice(1, 0, "-".repeat(columnWidths.reduce((a, c) => a + c + 2))) - } - return "```\n" + resultRows.join("\n") + "```" - } -}) - /** @type {string[]} SPRITE SHEET EMOJIS FEATURE: mxc urls for the currently processing message */ let endOfMessageEmojis = [] turndownService.addRule("emoji", { @@ -281,18 +258,7 @@ async function getMemberFromCacheOrHomeserver(roomID, mxid, api) { const row = select("member_cache", ["displayname", "avatar_url"], {room_id: roomID, mxid}).get() if (row) return row return api.getStateEvent(roomID, "m.room.member", mxid).then(event => { - const room = select("channel_room", "room_id", {room_id: roomID}).get() - if (room) { - // save the member to the cache so we don't have to check with the homeserver next time - // the cache will be kept in sync by the `m.room.member` event listener - const displayname = event?.displayname || null - const avatar_url = event?.avatar_url || null - db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?").run( - roomID, mxid, - displayname, avatar_url, - displayname, avatar_url - ) - } + db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(roomID, mxid, event?.displayname || null, event?.avatar_url || null) return event }).catch(() => { return {displayname: null, avatar_url: null} @@ -338,7 +304,7 @@ function getUserOrProxyOwnerID(mxid) { * At the time of this executing, we know what the end of message emojis are, and we know that at least one of them is unknown. * This function will strip them from the content and generate the correct pending file of the sprite sheet. * @param {string} content - * @param {{id: string, filename: string}[]} attachments + * @param {{id: string, name: string}[]} attachments * @param {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} pendingFiles * @param {(mxc: string) => Promise } mxcDownloader function that will download the mxc URLs and convert to uncompressed PNG data. use `getAndConvertEmoji` or a mock. */ @@ -352,9 +318,9 @@ async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, // Create a sprite sheet of known and unknown emojis from the end of the message const buffer = await emojiSheet.compositeMatrixEmojis(endOfMessageEmojis, mxcDownloader) // Attach it - const filename = "emojis.png" - attachments.push({id: String(attachments.length), filename}) - pendingFiles.push({name: filename, buffer}) + const name = "emojis.png" + attachments.push({id: String(attachments.length), name}) + pendingFiles.push({name, buffer}) return content } @@ -449,7 +415,7 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) { allowedMentionsParse: ["everyone"] } } - } else if (writtenMentionMatch[1].length < 40) { // the API supports up to 100 characters, but really if you're searching more than 40, something messed up + } else { const results = await di.snow.guild.searchGuildMembers(guild.id, {query: writtenMentionMatch[1]}) if (results[0]) { assert(results[0].user) @@ -481,23 +447,6 @@ const attachmentEmojis = new Map([ ["m.file", "📄"] ]) -async function getL1L2ReplyLine(called = false) { - // @ts-ignore - const autoEmoji = new Map(select("auto_emoji", ["name", "emoji_id"], {}, "WHERE name = 'L1' OR name = 'L2'").raw().all()) - if (autoEmoji.size === 2) { - return `<:L1:${autoEmoji.get("L1")}><:L2:${autoEmoji.get("L2")}>` - } - /* c8 ignore start */ - if (called) { - // Don't know how this could happen, but just making sure we don't enter an infinite loop. - console.warn("Warning: OOYE is missing data to format replies. To fix this: `npm run setup`") - return "" - } - await setupEmojis.setupEmojis() - return getL1L2ReplyLine(true) - /* c8 ignore stop */ -} - /** * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File} event * @param {import("discord-api-types/v10").APIGuild} guild @@ -516,7 +465,7 @@ async function eventToMessage(event, guild, di) { // Try to extract an accurate display name and avatar URL from the member event const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api) if (member.displayname) displayName = member.displayname - if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url) + if (member.avatar_url) avatarURL = mxUtils.getPublicUrlForMxc(member.avatar_url) || undefined // If the display name is too long to be put into the webhook (80 characters is the maximum), // put the excess characters into displayNameRunoff, later to be put at the top of the message let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName) @@ -526,7 +475,6 @@ async function eventToMessage(event, guild, di) { } let content = event.content.body // ultimate fallback - /** @type {{id: string, filename: string}[]} */ const attachments = [] /** @type {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */ const pendingFiles = [] @@ -534,45 +482,7 @@ async function eventToMessage(event, guild, di) { const ensureJoined = [] // Convert content depending on what the message is - // Handle images first - might need to handle their `body`/`formatted_body` as well, which will fall through to the text processor - let shouldProcessTextEvent = event.type === "m.room.message" && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote") - 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 - if ("file" in event.content) { - // Encrypted - assert.equal(event.content.file.key.alg, "A256CTR") - attachments.push({id: "0", filename}) - pendingFiles.push({name: filename, mxc: event.content.file.url, key: event.content.file.key.k, iv: event.content.file.iv}) - } else { - // Unencrypted - attachments.push({id: "0", filename}) - pendingFiles.push({name: filename, mxc: event.content.url}) - } - // Check if we also need to process a text event for this image - if it has a caption that's different from its filename - if ((event.content.body && event.content.filename && event.content.body !== event.content.filename) || event.content.formatted_body) { - shouldProcessTextEvent = true - } - } - 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 - } else { - const res = await di.api.getMedia(event.content.url, {method: "HEAD"}) - if (res.status === 200) { - mimetype = res.headers.get("content-type") - } - if (!mimetype) throw new Error(`Server error ${res.status} or missing content-type while detecting sticker mimetype`) - } - filename += "." + mimetype.split("/")[1] - } - attachments.push({id: "0", filename}) - pendingFiles.push({name: filename, mxc: event.content.url}) - } else if (shouldProcessTextEvent) { + if (event.type === "m.room.message" && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote")) { // Handling edits. If the edit was an edit of a reply, edits do not include the reply reference, so we need to fetch up to 2 more events. // this event ---is an edit of--> original event ---is a reply to--> past event await (async () => { @@ -586,12 +496,13 @@ async function eventToMessage(event, guild, di) { if (!messageIDsToEdit.length) return // Ok, it's an edit. - event = {...event, content: event.content["m.new_content"]} + event.content = event.content["m.new_content"] // Is it editing a reply? We need special handling if it is. // Get the original event, then check if it was a reply const originalEvent = await di.api.getEvent(event.room_id, originalEventId) - const repliedToEventId = originalEvent?.content?.["m.relates_to"]?.["m.in_reply_to"]?.event_id + if (!originalEvent) return + const repliedToEventId = originalEvent.content["m.relates_to"]?.["m.in_reply_to"]?.event_id if (!repliedToEventId) return // After all that, it's an edit of a reply. @@ -647,32 +558,41 @@ async function eventToMessage(event, guild, di) { return } - replyLine = await getL1L2ReplyLine() + // @ts-ignore + const autoEmoji = new Map(select("auto_emoji", ["name", "emoji_id"], {}, "WHERE name = 'L1' OR name = 'L2'").raw().all()) + replyLine = `<:L1:${autoEmoji.get("L1")}><:L2:${autoEmoji.get("L2")}>` const row = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get() if (row) { replyLine += `https://discord.com/channels/${guild.id}/${row.channel_id}/${row.message_id} ` } + const sender = repliedToEvent.sender + const authorID = getUserOrProxyOwnerID(sender) + if (authorID) { + replyLine += `<@${authorID}>` + } else { + let senderName = select("member_cache", "displayname", {mxid: sender}).pluck().get() + if (!senderName) { + const match = sender.match(/@([^:]*)/) + assert(match) + senderName = match[1] + } + replyLine += `**Ⓜ${senderName}**` + } // If the event has been edited, the homeserver will include the relation in `unsigned`. if (repliedToEvent.unsigned?.["m.relations"]?.["m.replace"]?.content?.["m.new_content"]) { repliedToEvent = repliedToEvent.unsigned["m.relations"]["m.replace"] // Note: this changes which event_id is in repliedToEvent. repliedToEvent.content = repliedToEvent.content["m.new_content"] } - /** @type {string} */ - let repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body - const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype) let contentPreview + const fileReplyContentAlternative = attachmentEmojis.get(repliedToEvent.content.msgtype) if (fileReplyContentAlternative) { contentPreview = " " + fileReplyContentAlternative } else if (repliedToEvent.unsigned?.redacted_because) { contentPreview = " (in reply to a deleted message)" - } else if (typeof repliedToContent !== "string") { - // in reply to a weird metadata event like m.room.name, m.room.member... - // I'm not implementing text fallbacks for arbitrary room events. this should cover most cases - // this has never ever happened in the wild anyway - repliedToEvent.sender = "" - contentPreview = " (channel details edited)" } else { // Generate a reply preview for a standard message + /** @type {string} */ + let repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body repliedToContent = repliedToContent.replace(/.*<\/mx-reply>/s, "") // Remove everything before replies, so just use the actual message body repliedToContent = repliedToContent.replace(/^\s* .*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards repliedToContent = repliedToContent.replace(/(?:\n|
)+/g, " ") // Should all be on one line @@ -693,15 +613,6 @@ async function eventToMessage(event, guild, di) { contentPreview = "" } } - const sender = repliedToEvent.sender - const authorID = getUserOrProxyOwnerID(sender) - if (authorID) { - replyLine += `<@${authorID}>` - } 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` })() @@ -857,13 +768,47 @@ async function eventToMessage(event, guild, di) { // @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 + } else { + const res = await di.api.getMedia(event.content.url, {method: "HEAD"}) + if (res.status === 200) { + mimetype = res.headers.get("content-type") + } + if (!mimetype) throw new Error(`Server error ${res.status} or missing content-type while detecting sticker mimetype`) + } + 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 | stream.Readable}[]})[]} */ + /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[]})[]} */ const messages = chunks.map(content => ({ content, allowed_mentions: { diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 3d1c918..145e9ec 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -559,7 +559,7 @@ test("event2message: lists are bridged correctly", async t => { "transaction_id": "m1692967313951.441" }, "event_id": "$l-xQPY5vNJo3SNxU9d8aOWNVD1glMslMyrp4M_JEF70", - "room_id": "!kLRqKKUQXcibIMtOpl:cadence.moe" + "room_id": "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -662,7 +662,7 @@ test("event2message: code block contents are formatted correctly and not escaped formatted_body: "\ninput = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n_input_ = input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n
\n" }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -692,7 +692,7 @@ test("event2message: code blocks use double backtick as delimiter when necessary formatted_body: "
input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,
backtick in ` the middle
,backtick at the edge`
" }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -722,7 +722,7 @@ test("event2message: inline code is converted to code block if it contains both formatted_body: "` one two ``
" }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -752,7 +752,7 @@ test("event2message: code blocks are uploaded as attachments instead if they con formatted_body: 'So if you run code like thisit should print a markdown formatted code block' }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -784,7 +784,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: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -821,7 +821,7 @@ test("event2message: characters are encoded properly in code blocks", async t => + '\n' }, event_id: "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!BpMdOUkWWhFxmTrENV:cadence.moe" }), { ensureJoined: [], @@ -902,7 +902,7 @@ test("event2message: lists have appropriate line breaks", async t => { 'm.mentions': {}, msgtype: 'm.text' }, - room_id: '!TqlyQmifxGUggEmdBN:cadence.moe', + room_id: '!cBxtVRxDlZvSVhJXVK:cadence.moe', sender: '@Milan:tchncs.de', type: 'm.room.message', }), @@ -943,7 +943,7 @@ test("event2message: ordered list start attribute works", async t => { 'm.mentions': {}, msgtype: 'm.text' }, - room_id: '!TqlyQmifxGUggEmdBN:cadence.moe', + room_id: '!cBxtVRxDlZvSVhJXVK:cadence.moe', sender: '@Milan:tchncs.de', type: 'm.room.message', }), @@ -1088,7 +1088,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: "System.out.println("```");
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 latestmain
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" @@ -1111,7 +1111,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 @cadence:cadence.moe
I just checked in a fix that will probably work, can you try reproducing this on the latestmain
branch and see if I fixed it?I just checked in a fix that will probably work, can you try reproducing this on the latest In reply to @solonovamax:matrix.orgmultipart messages will be deleted if the message is edited to require less space
\nsteps to reproduce:
\n\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
\nmain
branch and see if I fixed it?", + "formatted_body": "I just checked in a fix that will probably work, can you try reproducing this on the latest In reply to @solonovamax:matrix.orgmultipart messages will be deleted if the message is edited to require less space
\nsteps to reproduce:
\n\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
\nmain
branch and see if I fixed it?", "m.relates_to": { "m.in_reply_to": { "event_id": "$u4OD19vd2GETkOyhgFVla92oDKI4ojwBf2-JeVCG7EI" @@ -1123,7 +1123,7 @@ test("event2message: rich reply to a rich reply to a multi-line message should c "age": 19069564 }, "event_id": "$A0Rj559NKOh2VndCZSTJXcvgi42gZWVfVQt73wA2Hn0", - "room_id": "!TqlyQmifxGUggEmdBN:cadence.moe" + "room_id": "!cBxtVRxDlZvSVhJXVK:cadence.moe" }) }, snow: { @@ -2625,52 +2625,6 @@ 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: "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, { - 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: raw mentioning discord users in plaintext body works", async t => { t.deepEqual( await eventToMessage({ @@ -3531,7 +3485,7 @@ test("event2message: caches the member if the member is not known", async t => { }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", origin_server_ts: 1688301929913, - room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe", + room_id: "!should_be_newly_cached:cadence.moe", sender: "@should_be_newly_cached:cadence.moe", type: "m.room.message", unsigned: { @@ -3541,7 +3495,7 @@ test("event2message: caches the member if the member is not known", async t => { api: { getStateEvent: async (roomID, type, stateKey) => { called++ - t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") + t.equal(roomID, "!should_be_newly_cached:cadence.moe") t.equal(type, "m.room.member") t.equal(stateKey, "@should_be_newly_cached:cadence.moe") return { @@ -3565,60 +3519,12 @@ test("event2message: caches the member if the member is not known", async t => { } ) - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!qzDBLKlildpzrrOnFZ:cadence.moe"}).all(), [ + t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached: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( @@ -3674,7 +3580,7 @@ test("event2message: overly long usernames are shifted into the message content" }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", origin_server_ts: 1688301929913, - room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe", + room_id: "!should_be_newly_cached_2:cadence.moe", sender: "@should_be_newly_cached_2:cadence.moe", type: "m.room.message", unsigned: { @@ -3684,7 +3590,7 @@ test("event2message: overly long usernames are shifted into the message content" api: { getStateEvent: async (roomID, type, stateKey) => { called++ - t.equal(roomID, "!cqeGDbPiMFAhLsqqqq:cadence.moe") + t.equal(roomID, "!should_be_newly_cached_2:cadence.moe") t.equal(type, "m.room.member") t.equal(stateKey, "@should_be_newly_cached_2:cadence.moe") return { @@ -3707,7 +3613,7 @@ test("event2message: overly long usernames are shifted into the message content" }] } ) - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe"}).all(), [ + t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached_2: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") @@ -3722,7 +3628,7 @@ test("event2message: overly long usernames are not treated specially when the ms }, event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", origin_server_ts: 1688301929913, - room_id: "!cqeGDbPiMFAhLsqqqq:cadence.moe", + room_id: "!should_be_newly_cached_2:cadence.moe", sender: "@should_be_newly_cached_2:cadence.moe", type: "m.room.message", unsigned: { @@ -3770,7 +3676,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", filename: "chiki-powerups.txt"}], + attachments: [{id: "0", description: undefined, filename: "chiki-powerups.txt"}], pendingFiles: [{name: "chiki-powerups.txt", mxc: "mxc://cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH"}] }] } @@ -3806,14 +3712,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", filename: "cool cat.png"}], + attachments: [{id: "0", description: undefined, filename: "cool cat.png"}], pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] }] } ) }) -test("event2message: image attachments can have a plaintext caption", async t => { +test("event2message: image attachments can have a custom description", async t => { t.deepEqual( await eventToMessage({ type: "m.room.message", @@ -3840,62 +3746,10 @@ test("event2message: image attachments can have a plaintext caption", async t => messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "Cat emoji surrounded by pink hearts", + 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"}], - 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 In reply to @ampflower:matrix.org changed the room topicformatting
", - 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"] - } + attachments: [{id: "0", description: "Cat emoji surrounded by pink hearts", filename: "cool cat.png"}], + pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] }] } ) @@ -3944,7 +3798,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", filename: "image.png"}], + attachments: [{id: "0", description: undefined, filename: "image.png"}], pendingFiles: [{ name: "image.png", mxc: "mxc://heyquark.com/LOGkUTlVFrqfiExlGZNgCJJX", @@ -3956,91 +3810,6 @@ 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: stickers work", async t => { t.deepEqual( await eventToMessage({ @@ -4622,42 +4391,6 @@ test("event2message: @room in the middle of a link is not converted", async t => ) }) -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: "contentmore 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: unknown emoji at the end is reuploaded as a sprite sheet", async t => { const messages = await eventToMessage({ type: "m.room.message", @@ -4744,7 +4477,7 @@ slow()("event2message: all unknown chess emojis are reuploaded as a sprite sheet formatted_body: "testing
Col 1 Col 2 Col 3 Apple Banana Cherry Aardvark Bee Crocodile Argon Boron Carbon " }, event_id: "$Me6iE8C8CZyrDEOYYrXKSYRuuh_25Jj9kZaNrf7LKr4", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" + room_id: "!maggESguZBqGBZtSnr:cadence.moe" }, {}, {mxcDownloader: mockGetAndConvertEmoji}) const testResult = { content: messages.messagesToSend[0].content, diff --git a/src/m2d/converters/utils.js b/src/m2d/converters/utils.js index 41cb0af..17cb0fd 100644 --- a/src/m2d/converters/utils.js +++ b/src/m2d/converters/utils.js @@ -217,12 +217,12 @@ async function getViaServersQuery(roomID, api) { * @see https://matrix.org/blog/2024/06/20/matrix-v1.11-release/ implementation details * @see https://www.sqlite.org/fileformat2.html#record_format SQLite integer field size * @param {string} mxc - * @returns {string | undefined} + * @returns {string?} */ function getPublicUrlForMxc(mxc) { assert(hasher, "xxhash is not ready yet") const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) - if (!mediaParts) return undefined + if (!mediaParts) return null const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}` const unsignedHash = hasher.h64(serverAndMediaID) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index ce3638c..a270293 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -22,132 +22,43 @@ const matrixCommandHandler = sync.require("../matrix/matrix-command-handler") const utils = sync.require("./converters/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") 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; -} - -/** - * @param {string} roomID - * @param {"Discord" | "Matrix"} source - * @param {any} type - * @param {any} e - * @param {any} payload - */ -async function sendError(roomID, source, type, e, payload) { - console.error(`Error while processing a ${type} ${source} event:`) - console.error(e) - console.dir(payload, {depth: null}) - - if (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}`, ``) - - // How - builder.addLine("", `Error trace
${stack}`) - } - - // 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) { - await sendError(event.room_id, "Matrix", type, e, event) + 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" + + `Original payload
${util.inspect(payload, false, 4, false)}
Event type: ${type}` + + `
${e.toString()}` + + `` + + `Error trace
` + + `${stackLines.join("\n")}`, + "moe.cadence.ooye.error": { + source: "matrix", + payload: event + }, + "m.mentions": { + user_ids: ["@cadence:cadence.moe"] + } + }) } } } @@ -179,7 +90,7 @@ async function onRetryReactionAdd(reactionEvent) { } // Redact the error to stop people from executing multiple retries - await api.redactEvent(roomID, event.event_id) + api.redactEvent(roomID, event.event_id) } sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", @@ -189,7 +100,6 @@ 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) @@ -254,20 +164,6 @@ 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.StateOuterOriginal payload
` + + `${util.inspect(event, false, 4, false)}} 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 @@ -296,75 +192,25 @@ async event => { await api.ackEvent(event) })) -function getFromInviteRoomState(inviteRoomState, nskey, key) { - if (!Array.isArray(inviteRoomState)) return null - for (const event of inviteRoomState) { - if (event.type === nskey && event.state_key === "") { - return event.content[key] - } - } - return null -} - -sync.addTemporaryListener(as, "type:m.space.child", guard("m.space.child", -/** - * @param {Ty.Event.StateOuter } event - */ -async event => { - if (Array.isArray(event.content.via) && event.content.via.length) { // space child is being added - await api.joinRoom(event.state_key).catch(() => {}) // try to join if able, it's okay if it doesn't want, bot will still respond to invites - } -})) - 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 (event.content.membership === "invite" && event.state_key === `@${reg.sender_localpart}:${reg.ooye.server_name}`) { - // We were invited to a room. We should join, and register the invite details for future reference in web. - const name = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.name", "name") - const topic = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.topic", "topic") - const avatar = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.avatar", "url") - const creationType = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.create", "type") - if (!name) return await api.leaveRoomWithReason(event.room_id, "Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite!") - await api.joinRoom(event.room_id) - db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, creationType, name, topic, avatar) - if (avatar) utils.getPublicUrlForMxc(avatar) // make sure it's available in the media_proxy allowed URLs - } - if (utils.eventSenderIsFromDiscord(event.state_key)) return - if (event.content.membership === "leave" || event.content.membership === "ban") { // Member is gone db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) - - // Unregister room's use as a direct chat if the bot itself left - const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}` - if (event.state_key === bot) { - db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id) - } + } 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 + ) } - - const exists = select("channel_room", "room_id", {room_id: event.room_id}) ?? select("guild_space", "space_id", {space_id: event.room_id}) - if (!exists) return // don't cache members in unbridged rooms - - // Member is here - let powerLevel = 0 - try { - /** @type {Ty.Event.M_Power_Levels} */ - const powerLevelsEvent = await api.getStateEvent(event.room_id, "m.room.power_levels", "") - powerLevel = powerLevelsEvent.users?.[event.state_key] ?? powerLevelsEvent.users_default ?? 0 - } catch (e) {} - const displayname = event.content.displayname || null - const avatar_url = event.content.avatar_url - db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?, power_level = ?").run( - event.room_id, event.state_key, - displayname, avatar_url, powerLevel, - displayname, avatar_url, powerLevel - ) })) sync.addTemporaryListener(as, "type:m.room.power_levels", guard("m.room.power_levels", @@ -379,6 +225,3 @@ async event => { db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(newPower[mxid] || 0, event.room_id, mxid) } })) - -module.exports.stringifyErrorStack = stringifyErrorStack -module.exports.sendError = sendError diff --git a/src/m2d/event-dispatcher.test.js b/src/m2d/event-dispatcher.test.js deleted file mode 100644 index de754da..0000000 --- a/src/m2d/event-dispatcher.test.js +++ /dev/null @@ -1,23 +0,0 @@ -// @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 41af63f..c2b2384 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -2,7 +2,8 @@ const Ty = require("../types") const assert = require("assert").strict -const streamWeb = require("stream/web") + +const fetch = require("node-fetch").default const passthrough = require("../passthrough") const {sync} = passthrough @@ -81,16 +82,6 @@ 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 @@ -181,23 +172,6 @@ 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 @@ -308,33 +282,21 @@ async function profileSetAvatarUrl(mxid, avatar_url) { * Set a user's power level within a room. * @param {string} roomID * @param {string} mxid - * @param {number} newPower + * @param {number} power */ -async function setUserPower(roomID, mxid, newPower) { +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 power = await getStateEvent(roomID, "m.room.power_levels", "") - power.users = power.users || {} - - // Check if it has really changed to avoid sending a useless state event - // (Can't diff kstate here because of (a) circular imports (b) kstate has special behaviour diffing power levels) - const oldPowerLevel = power.users?.[mxid] ?? power.users_default ?? 0 - if (oldPowerLevel === newPower) return - - // Bridge bot can't demote equal power users, so need to decide which user will send the event - const botPowerLevel = power.users?.[`@${reg.sender_localpart}:${reg.ooye.server_name}`] ?? power.users_default ?? 0 - const eventSender = oldPowerLevel >= botPowerLevel ? mxid : undefined - - // Update the event content - if (newPower == null || newPower === (power.users_default ?? 0)) { - delete power.users[mxid] + const powerLevels = await getStateEvent(roomID, "m.room.power_levels", "") + powerLevels.users = powerLevels.users || {} + if (power != null) { + powerLevels.users[mxid] = power } else { - power.users[mxid] = newPower + delete powerLevels.users[mxid] } - - await sendState(roomID, "m.room.power_levels", "", power, eventSender) - return power + await sendState(roomID, "m.room.power_levels", "", powerLevels) + return powerLevels } /** @@ -372,21 +334,17 @@ async function ping() { /** * @param {string} mxc - * @param {RequestInit} [init] - * @return {Promise }>} + * @param {fetch.RequestInit} [init] */ -async function getMedia(mxc, init = {}) { +function getMedia(mxc, init = {}) { const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) assert(mediaParts) - const res = await fetch(`${mreq.baseUrl}/client/v1/media/download/${mediaParts[1]}/${mediaParts[2]}`, { + return fetch(`${mreq.baseUrl}/client/v1/media/download/${mediaParts[1]}/${mediaParts[2]}`, { headers: { Authorization: `Bearer ${reg.as_token}` }, ...init }) - assert(res.body) - // @ts-ignore - return res } /** @@ -419,50 +377,12 @@ async function getAlias(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}`) -} - module.exports.path = path module.exports.register = register 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.getAllState = getAllState @@ -471,7 +391,6 @@ 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 @@ -487,7 +406,3 @@ 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 diff --git a/src/matrix/file.js b/src/matrix/file.js index 2070a56..f0ee29a 100644 --- a/src/matrix/file.js +++ b/src/matrix/file.js @@ -1,9 +1,8 @@ // @ts-check -const passthrough = require("../passthrough") -const {reg, writeRegistration} = require("./read-registration.js") -const Ty = require("../types") +const fetch = require("node-fetch").default +const passthrough = require("../passthrough") const {sync, db, select} = passthrough /** @type {import("./mreq")} */ const mreq = sync.require("./mreq") @@ -47,8 +46,11 @@ async function uploadDiscordFileToMxc(path) { return existingFromDb } - // Download from Discord and upload to Matrix - const promise = module.exports._actuallyUploadDiscordFileToMxc(url).then(root => { + // 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) + // Store relationship in database db.prepare("INSERT INTO file (discord_url, mxc_url) VALUES (?, ?)").run(urlNoExpiry, root.content_uri) inflight.delete(urlNoExpiry) @@ -60,33 +62,15 @@ async function uploadDiscordFileToMxc(path) { return promise } -/** - * @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 +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") } - throw e - } + }) + return root } function guildIcon(guild) { @@ -98,8 +82,8 @@ 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) { @@ -109,17 +93,18 @@ function emoji(emojiID, animated) { } const stickerFormat = new Map([ - [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/"}] + [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"}] ]) /** @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)}`) - return `${format.endpoint}${sticker.id}.${format.ext}` + const ext = format.ext + return `/stickers/${sticker.id}.${ext}` } module.exports.DISCORD_IMAGES_BASE = DISCORD_IMAGES_BASE diff --git a/src/matrix/kstate.js b/src/matrix/kstate.js index 03d09e0..11155f5 100644 --- a/src/matrix/kstate.js +++ b/src/matrix/kstate.js @@ -87,12 +87,6 @@ function diffKState(actual, target) { diff[key] = temp } - } else if (key === "chat.schildi.hide_ui/read_receipts") { - // Special handling: don't add this key if it's new. Do overwrite if already present. - if (key in actual) { - diff[key] = target[key] - } - } else if (key in actual) { // diff if (!isDeepStrictEqual(actual[key], target[key])) { diff --git a/src/matrix/kstate.test.js b/src/matrix/kstate.test.js index 1b67ad5..0538450 100644 --- a/src/matrix/kstate.test.js +++ b/src/matrix/kstate.test.js @@ -234,31 +234,3 @@ test("diffKState: kstate keys must contain a slash separator", t => { , /does not contain a slash separator/) t.pass() }) - -test("diffKState: don't add hide_ui when not present", t => { - test("diffKState: detects new properties", t => { - t.deepEqual( - diffKState({ - }, { - "chat.schildi.hide_ui/read_receipts/": {} - }), - { - } - ) - }) -}) - -test("diffKState: overwriten hide_ui when present", t => { - test("diffKState: detects new properties", t => { - t.deepEqual( - diffKState({ - "chat.schildi.hide_ui/read_receipts/": {hidden: true} - }, { - "chat.schildi.hide_ui/read_receipts/": {} - }), - { - "chat.schildi.hide_ui/read_receipts/": {} - } - ) - }) -}) diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 93bc312..7a35e12 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -224,7 +224,7 @@ const commands = [{ .png() .toBuffer({resolveWithObject: true}) console.log(`uploading emoji ${resizeOutput.data.length} bytes to :${e.name}:`) - await discord.snow.assets.createGuildEmoji(guildID, {name: e.name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")}) + const emoji = await discord.snow.guildAssets.createEmoji(guildID, {name: e.name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")}) } api.sendEvent(event.room_id, "m.room.message", { ...ctx, diff --git a/src/matrix/mreq.js b/src/matrix/mreq.js index 9179825..8a76b11 100644 --- a/src/matrix/mreq.js +++ b/src/matrix/mreq.js @@ -1,11 +1,12 @@ // @ts-check -const stream = require("stream") -const streamWeb = require("stream/web") -const {buffer} = require("stream/consumers") +const fetch = require("node-fetch").default const mixin = require("@cloudrac3r/mixin-deep") +const stream = require("stream") +const getStream = require("get-stream") + +const {reg, writeRegistration} = require("./read-registration.js") -const {reg} = require("./read-registration.js") const baseUrl = `${reg.ooye.server_origin}/_matrix` class MatrixServerError extends Error { @@ -18,49 +19,43 @@ 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 {string} method * @param {string} url - * @param {string | object | streamWeb.ReadableStream | stream.Readable} [bodyIn] + * @param {any} [body] * @param {any} [extra] */ -async function mreq(method, url, bodyIn, extra = {}) { - const body = await _convertBody(bodyIn) +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) + } - /** @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() if (!res.ok || root.errcode) { - delete opts.headers?.["Authorization"] + if (root.error?.includes("Content-Length") && !reg.ooye.content_length_workaround) { + reg.ooye.content_length_workaround = true + const root = await mreq(method, url, body, extra) + 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 + } + delete opts.headers.Authorization throw new MatrixServerError(root, {baseUrl, url, ...opts}) } return root @@ -88,4 +83,3 @@ module.exports.MatrixServerError = MatrixServerError 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 deleted file mode 100644 index 7ac343e..0000000 --- a/src/matrix/mreq.test.js +++ /dev/null @@ -1,47 +0,0 @@ -// @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/passthrough.js b/src/passthrough.js index 8eedfc4..378f516 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")} sync + * @property {import("heatsync").default} 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 fea5fad..9051395 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, select, from, as} = passthrough +const {discord, sync, db} = passthrough const data = sync.require("../test/data") const createSpace = sync.require("./d2m/actions/create-space") @@ -19,18 +19,19 @@ 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(() => { + setImmediate(() => { // assign after since old extraContext data will get removed if (!passthrough.repl) { const cli = repl.start({ prompt: "", eval: customEval, writer: s => s }) - Object.assign(cli.context, passthrough) + Object.assign(cli.context, extraContext, passthrough) passthrough.repl = cli + } else { + Object.assign(passthrough.repl.context, extraContext) } - // @ts-ignore sync.addTemporaryListener(passthrough.repl, "exit", () => process.exit()) }) } @@ -59,3 +60,9 @@ 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 37da633..3298c40 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -28,9 +28,6 @@ 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 } old_bridge?: { as_token: string @@ -167,8 +164,6 @@ 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 @@ -186,8 +181,6 @@ 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 file: { url: string @@ -248,10 +241,6 @@ export namespace Event { name?: string } - export type M_Room_Topic = { - topic?: string - } - export type M_Room_PinnedEvents = { pinned: string[] } @@ -286,11 +275,6 @@ 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" diff --git a/src/web/auth.js b/src/web/auth.js deleted file mode 100644 index c14dcd8..0000000 --- a/src/web/auth.js +++ /dev/null @@ -1,33 +0,0 @@ -// @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 f49f5a2..057fd0a 100644 --- a/src/web/pug-sync.js +++ b/src/web/pug-sync.js @@ -5,13 +5,10 @@ const fs = require("fs") const {join} = require("path") const getRelativePath = require("get-relative-path") const h3 = require("h3") -const {defineEventHandler, defaultContentType, setResponseStatus, getQuery} = h3 +const {defineEventHandler, defaultContentType, setResponseStatus, useSession, getQuery} = h3 const {compileFile} = require("@cloudrac3r/pug") -const pretty = process.argv.join(" ").includes("test") -const {sync} = require("../passthrough") -/** @type {import("./auth")} */ -const auth = sync.require("./auth") +const {reg} = require("../matrix/read-registration") // Pug @@ -34,24 +31,16 @@ function render(event, filename, locals) { function compile() { try { - const template = compileFile(path, {pretty}) + const template = compileFile(path, {}) pugCache.set(path, async (event, locals) => { defaultContentType(event, "text/html; charset=utf-8") - 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 - } + const session = await useSession(event, {password: reg.as_token}) + const rel = x => getRelativePath(event.path, x) 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, event, rel, managed} // These are assigned last so they overwrite everything else. It would be catastrophically bad if they can't be trusted. + {session, event, rel} // These are assigned last so they overwrite everything else. It would be catastrophically bad if they can't be trusted. )) }) /* c8 ignore start */ diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 92ffa1b..34178bf 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -11,9 +11,7 @@ mixin badge-private | Private mixin discord(channel, radio=false) - //- Previously, we passed guild.roles as the second parameter, but this doesn't quite match Discord's behaviour. See issue #42 for why this was changed. - //- Basically we just want to assign badges based on the channel overwrites, without considering the guild's base permissions. /shrug - - let permissions = dUtils.getPermissions([], [{id: guild_id, name: "@everyone", permissions: 1<<10 | 1<<11}], null, channel.permission_overwrites) + - let permissions = dUtils.getPermissions([], guild.roles, null, channel.permission_overwrites) .s-user-card.s-user-card__small if !dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) != icons.Icons.IconLock @@ -54,13 +52,13 @@ block body .s-page-title.mb24 h1.s-page-title--header= guild.name - .d-flex.g16(class="sm:fw-wrap") + .d-flex.g16 .fl-grow1 h2.fs-headline1 Invite a Matrix user - 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") + 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" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)") + 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") @@ -69,151 +67,89 @@ block body 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 + button.s-btn.s-btn__filled.htmx-indicator 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 + != svg - if space_id - 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) + h2.mt48.fs-headline1 Moderation - .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 + h2.mt48.fs-headline1 Matrix setup - 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 + h3.mt32.fs-category Linked channels - 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 + .s-card.bs-sm.p0 + .s-table-container + 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: +matrix(row) + else + tr + td(colspan="3") + .s-empty-state No channels linked between Discord and Matrix yet... - 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 - - h2.mt48.fs-headline1 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. - - 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. - - if space_id - h2.mt48.fs-headline1 Channel setup - - h3.mt32.fs-category Linked channels - .s-card.bs-sm.p0 - form.s-table-container(method="post" action=rel("/api/unlink") hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources.") - 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(name="channel_id" value=row.channel.id hx-post=rel("/api/unlink") hx-trigger="click" 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.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") + 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. - - if space_id - h3.mt32.fs-category Manually link channels - 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" 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 - 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. + - let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get() input(type="hidden" name="guild_id" value=guild_id) - div - button.s-btn.s-btn__icon.s-btn__filled#link-button - != icons.Icons.IconMerge - = ` Link` + 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 - 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: Bridge 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 + h3.mt32.fs-category Privacy level + .s-card + form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="this") + input(type="hidden" name="guild_id" value=guild_id) + .d-flex.ai-center.mb4 + label.s-label.fl-grow1 + | How people can join on Matrix + span.is-loading#privacy-level-loading + .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="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 + + input(type="radio" name="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 + + input(type="radio" name="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 + + 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 + + h3.mt32.fs-category Manually link channels + form.d-flex.g16.ai-start(hx-post="/api/link" hx-trigger="submit" hx-disabled-elt="this") + .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) + 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 + each room in unlinkedRooms + input.s-btn--radio(type="radio" name="matrix" 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.htmx-indicator + != icons.Icons.IconMerge + = ` Link` diff --git a/src/web/pug/guild_access_denied.pug b/src/web/pug/guild_access_denied.pug index 42fea7b..2b47e08 100644 --- a/src/web/pug/guild_access_denied.pug +++ b/src/web/pug/guild_access_denied.pug @@ -1,17 +1,13 @@ extends includes/template.pug block body - if !session.data.userID + if !session.data.managedGuilds .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` + a.s-btn.s-btn__icon.s-btn__filled(href=rel("/oauth")) + != icons.Icons.IconDiscord + = ` Log in with Discord` else if !guild_id .s-empty-state.wmx4.p48 @@ -19,18 +15,8 @@ block body 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) + else if !discord.guilds.has(guild_id) || !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=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 deleted file mode 100644 index 59de2fb..0000000 --- a/src/web/pug/guild_not_linked.pug +++ /dev/null @@ -1,53 +0,0 @@ -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)) - 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 Auto-create - .s-card - form.d-flex.ai-center.g8(method="post" action=rel("/api/autocreate") hx-post=rel("/api/autocreate") hx-indicator="#easy-mode-button") - input(type="hidden" name="guild_id" value=guild_id) - input(type="hidden" name="autocreate" value="true") - label.s-label.fl-grow1 - | Changed your mind? - p.s-description If you want, OOYE can create and manage the Matrix space so you don't have to. - button.s-btn.s-btn__outlined#easy-mode-button Use easy mode diff --git a/src/web/pug/home.pug b/src/web/pug/home.pug index d562250..a906423 100644 --- a/src/web/pug/home.pug +++ b/src/web/pug/home.pug @@ -1,56 +1,24 @@ extends includes/template.pug block body - - let locked = reg.ooye.web_password && reg.ooye.web_password !== session.data.password + .s-page-title.mb24 + h1.s-page-title--header Bridge a Discord server - 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 - h2 What is this? - p #[a(href="https://gitdab.com/cadence/out-of-your-element") Out Of Your Element] is a bridge between the Discord and Matrix chat apps. It lets people on both platforms chat with each other without needing to get everyone on the same app. - p Just chat like usual, and the bridge will forward messages back and forth between the two platforms, so everyone sees the whole conversation. - p All kinds of content are supported, including pictures, threads, emojis, and @mentions. - p It's really easy to set up, even if you only have Discord. Just add the bot to your server, and it'll make everything available on Matrix automatically. - - if locked - h2 This is a private instance - p Anybody can run their own instance of the Out Of Your Element software. The person running this instance has made it private, so you can't add it to your server just yet. If you know who's in charge of #{reg.ooye.server_name}, ask them for the password. - - 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.] + .d-grid.g24.grid__2(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` diff --git a/src/web/pug/includes/hash.svg b/src/web/pug/includes/hash.svg index 461f2dc..0f6fdd7 100644 --- a/src/web/pug/includes/hash.svg +++ b/src/web/pug/includes/hash.svg @@ -1 +1,46 @@ - + + diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index d9f1c30..6a0fb76 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -1,148 +1,71 @@ -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 - -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")) - - 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; - } - +define-themed-button("matrix", "black") - 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=rel("/")) - img.s-avatar.s-avatar__32(src=rel("/icon.png")) - 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) - li(role="menuitem") - a.s-topbar--item.s-user-card.d-flex.p4(href=rel(`/guild?guild_id=${guild.id}`)) - +guild(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) - }) - }) - 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 - }) +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=rel("/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%; + } + .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); + } + 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=rel("/")) + img.s-avatar.s-avatar__32(src=rel("/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=rel("/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=rel(`/guild?guild_id=${guild.id}`)) + +guild(guild) + .mx-auto.w100.wmx9.py24.px8.fs-body1#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=rel("/static/htmx.min.js")) diff --git a/src/web/pug/invite.pug b/src/web/pug/invite.pug index 81a428d..7f7ff2e 100644 --- a/src/web/pug/invite.pug +++ b/src/web/pug/invite.pug @@ -13,11 +13,11 @@ block body .s-page-title.mb24 h1.s-page-title--header= guild.name - .d-flex.g16#form-container + .d-flex.g16 .fl-grow1 h2.fs-headline1 Invite a Matrix user - 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") + form.d-flex.gy16.fd-column(method="post" action="/api/invite" style="grid-template-rows: repeat(2, auto)") .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") @@ -30,4 +30,4 @@ block body option(value="admin") Admin input(type="hidden" name="nonce" value=nonce) div - button.s-btn.s-btn__filled#invite-button Invite + button.s-btn.s-btn__filled.htmx-indicator Invite diff --git a/src/web/pug/log-in-with-matrix.pug b/src/web/pug/log-in-with-matrix.pug deleted file mode 100644 index 3bb72e2..0000000 --- a/src/web/pug/log-in-with-matrix.pug +++ /dev/null @@ -1,16 +0,0 @@ -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 bf32e8d..9aed737 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#ok - != spot ? icons.Spots[spot] : icons.Spots.SpotApproveXL + .ta-center.wmx5.p48.mx-auto + != icons.Spots.SpotApproveXL p.mt24.fs-body2= msg diff --git a/src/web/routes/download-matrix.test.js b/src/web/routes/download-matrix.test.js index 421d2da..d44271a 100644 --- a/src/web/routes/download-matrix.test.js +++ b/src/web/routes/download-matrix.test.js @@ -3,6 +3,7 @@ const tryToCatch = require("try-to-catch") const {test} = require("supertape") const {router} = require("../../../test/web") +const fetch = require("node-fetch") test("web download matrix: access denied if not a known attachment", async t => { const [error] = await tryToCatch(() => @@ -26,7 +27,7 @@ test("web download matrix: works if a known attachment", async t => { event, api: { async getMedia(mxc, init) { - return new Response("", {status: 200, headers: {"content-type": "image/png"}}) + return new fetch.Response("", {status: 200, headers: {"content-type": "image/png"}}) } } }) diff --git a/src/web/routes/guild-settings.js b/src/web/routes/guild-settings.js index b640d36..1c33854 100644 --- a/src/web/routes/guild-settings.js +++ b/src/web/routes/guild-settings.js @@ -2,93 +2,44 @@ const assert = require("assert/strict") const {z} = require("zod") -const {defineEventHandler, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect, H3Event} = require("h3") +const {defineEventHandler, sendRedirect, useSession, createError, readValidatedBody} = require("h3") -const {as, db, sync, select} = require("../../passthrough") +const {as, db, sync} = require("../../passthrough") +const {reg} = require("../../matrix/read-registration") -/** @type {import("../auth")} */ -const auth = sync.require("../auth") -/** @type {import("../../d2m/actions/set-presence")} */ -const setPresence = sync.require("../../d2m/actions/set-presence") +/** @type {import("../../d2m/actions/create-space")} */ +const createSpace = sync.require("../../d2m/actions/create-space") -/** - * @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 +/** @type {["invite", "link", "directory"]} */ +const levels = ["invite", "link", "directory"] +const schema = { + autocreate: z.object({ + guild_id: z.string(), + autocreate: z.string().optional() + }), + privacyLevel: z.object({ + guild_id: z.string(), + level: z.enum(levels) }) } -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/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 null // 204 })) -as.router.post("/api/url-preview", defineToggle("url_preview")) +as.router.post("/api/privacy-level", defineEventHandler(async event => { + const parsedBody = await readValidatedBody(event, schema.privacyLevel.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"}) -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 - } + const i = levels.indexOf(parsedBody.level) + assert.notEqual(i, -1) + db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(i, parsedBody.guild_id) + await createSpace.syncSpaceFully(parsedBody.guild_id) // this is inefficient but OK to call infrequently on user request + return null // 204 })) diff --git a/src/web/routes/guild-settings.test.js b/src/web/routes/guild-settings.test.js deleted file mode 100644 index fccc266..0000000 --- a/src/web/routes/guild-settings.test.js +++ /dev/null @@ -1,96 +0,0 @@ -// @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 index 8c2d99d..5944131 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -1,33 +1,24 @@ // @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 {H3Event, defineEventHandler, sendRedirect, useSession, createError, getValidatedQuery, readValidatedBody} = 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") +const {discord, as, sync, select} = 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 utils = sync.require("../../discord/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"]), @@ -52,31 +43,10 @@ function getAPI(event) { const validNonce = new LRUCache({max: 200}) /** - * Modifies the input, removing items that don't pass the filter. Returns the items that didn't pass. - * @param {T[]} xs - * @param {(x: T, i?: number) => any} fn - * @template T - * @returns T[] - */ -function filterTo(xs, fn) { - /** @type {T[]} */ - const filtered = [] - for (let i = xs.length-1; i >= 0; i--) { - const x = xs[i] - if (!fn(x, i)) { - filtered.unshift(x) - xs.splice(i, 1) - } - } - return filtered -} - -/** - * @param {DiscordTypes.APIGuild} guild + * @param {string} guildID * @param {Ty.R.Hierarchy[]} rooms - * @param {string[]} roles */ -function getChannelRoomsLinks(guild, rooms, roles) { +function getChannelRoomsLinks(guildID, rooms) { function getPosition(channel) { let position = 0 let looking = channel @@ -88,83 +58,37 @@ function getChannelRoomsLinks(guild, rooms, roles) { return position } - let channelIDs = discord.guildChannelMap.get(guild.id) + let channelIDs = discord.guildChannelMap.get(guildID) assert(channelIDs) let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {channel_id: channelIDs}).all() - let linkedChannelsWithDetails = linkedChannels.map(c => ({channel: discord.channels.get(c.channel_id), ...c})) - let removedUncachedChannels = filterTo(linkedChannelsWithDetails, c => c.channel) + let linkedChannelsWithDetails = linkedChannels.map(c => ({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)) let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c)) - /** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore - let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)) - let removedWrongTypeChannels = filterTo(unlinkedChannels, c => c && [0, 5].includes(c.type)) - let removedPrivateChannels = filterTo(unlinkedChannels, c => { - const permissions = utils.getPermissions(roles, guild.roles, botID, c["permission_overwrites"]) - return utils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel) - }) + let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)).filter(c => c && [0, 5].includes(c.type)) unlinkedChannels.sort((a, b) => getPosition(a) - getPosition(b)) let linkedRoomIDs = linkedChannels.map(c => c.room_id) - let unlinkedRooms = [...rooms] - let removedLinkedRooms = filterTo(unlinkedRooms, r => !linkedRoomIDs.includes(r.room_id)) - let removedWrongTypeRooms = filterTo(unlinkedRooms, r => !r.room_type) + let unlinkedRooms = rooms.filter(r => !linkedRoomIDs.includes(r.room_id) && !r.room_type) // https://discord.com/developers/docs/topics/threads#active-archived-threads // need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name - let removedArchivedThreadRooms = filterTo(unlinkedRooms, r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) + unlinkedRooms = unlinkedRooms.filter(r => r.name && !r.name.match(/^\[(🔒)?⛓️\]/)) - return { - linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms, - removedUncachedChannels, removedWrongTypeChannels, removedPrivateChannels, removedLinkedRooms, removedWrongTypeRooms, removedArchivedThreadRooms - } + return {linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms} } 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() + const session = await useSession(event, {password: reg.as_token}) + const row = select("guild_space", ["space_id", "privacy_level"], {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 = db.prepare("SELECT room_id, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id WHERE mxid = ? AND space_id IS NULL AND type = 'm.space'").all(session.data.mxid) - 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}) + if (!guild_id || !guild || !session.data.managedGuilds || !session.data.managedGuilds.includes(guild_id)) { + return pugSync.render(event, "guild_access_denied.pug", {guild_id}) } const nonce = randomUUID() @@ -177,7 +101,19 @@ as.router.get("/qr", defineEventHandler(async event => { const svg = generatedSvg.replace(/viewBox="0 0 ([0-9]+) ([0-9]+)"/, `data-nonce="${nonce}" width="$1" height="$2" $&`) assert.notEqual(svg, generatedSvg) - return svg + // Unlinked guild + if (!row) { + const links = getChannelRoomsLinks(guild_id, []) + return pugSync.render(event, "guild.pug", {guild, guild_id, svg, ...links}) + } + + // Linked guild + const api = getAPI(event) + const mods = await api.getStateEvent(row.space_id, "m.room.power_levels", "") + const banned = await api.getMembers(row.space_id, "ban") + const rooms = await api.getFullHierarchy(row.space_id) + const links = getChannelRoomsLinks(guild_id, rooms) + return pugSync.render(event, "guild.pug", {guild, guild_id, svg, mods, banned, ...links, ...row}) })) as.router.get("/invite", defineEventHandler(async event => { @@ -190,13 +126,13 @@ as.router.get("/invite", defineEventHandler(async event => { as.router.post("/api/invite", defineEventHandler(async event => { const parsedBody = await readValidatedBody(event, schema.invite.parse) - const managed = await auth.getManagedGuilds(event) + const session = await useSession(event, {password: reg.as_token}) 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"}) + 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) @@ -228,10 +164,9 @@ as.router.post("/api/invite", defineEventHandler(async event => { ( parsedBody.permissions === "admin" ? 100 : parsedBody.permissions === "moderator" ? 50 : 0) - if (powerLevel) await api.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel) + await api.setUserPowerCascade(spaceID, parsedBody.mxid, powerLevel) 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) diff --git a/src/web/routes/guild.test.js b/src/web/routes/guild.test.js index ea59173..3952d14 100644 --- a/src/web/routes/guild.test.js +++ b/src/web/routes/guild.test.js @@ -1,102 +1,67 @@ // @ts-check const tryToCatch = require("try-to-catch") -const {router, test} = require("../../../test/web") +const {test} = require("supertape") +const {router} = require("../../../test/web") const {MatrixServerError} = require("../../matrix/mreq") let nonce test("web guild: access denied when not logged in", async t => { - const html = await router.test("get", "/guild?guild_id=112760669178241024", { + const content = await router.test("get", "/guild?guild_id=112760669178241024", { sessionData: { }, }) - t.has(html, "You need to log in to manage your servers.") + t.match(content, /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", { + const content = await router.test("get", "/guild", { sessionData: { - userID: "1", managedGuilds: [] }, }) - t.has(html, "Select a server from the top right corner to continue.") + t.match(content, /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", { + const content = 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") + t.match(content, /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./) + const content = await router.test("get", "/invite?nonce=1") + t.match(content, /This QR code has expired./) }) test("web guild: can view unbridged guild", async t => { - const html = await router.test("get", "/guild?guild_id=66192955777486848", { + const content = await router.test("get", "/guild?guild_id=66192955777486848", { sessionData: { managedGuilds: ["66192955777486848"] + }, + api: { + async getStateEvent(roomID, type, key) { + return {} + }, + async getMembers(roomID, membership) { + return {chunk: []} + }, + async getFullHierarchy(roomID) { + return [] + } } }) - t.has(html, ` Function & Arg
`) + t.match(content, /{ - 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", { +test("web guild: can view bridged guild", async t => { + const content = await router.test("get", "/guild?guild_id=112760669178241024", { sessionData: { managedGuilds: ["112760669178241024"] }, @@ -112,42 +77,14 @@ test("web guild: can view bridged guild when logged in with discord", async t => } } }) - 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 getStateEvent(roomID, type, key) { - return {} - }, - async getMembers(roomID, membership) { - return {chunk: []} - }, - 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.match(content, /{ - const html = await router.test("get", `/invite?nonce=${nonce}`) - t.has(html, "Invite a Matrix user") + const content = await router.test("get", `/invite?nonce=${nonce}`) + t.match(content, /Invite a Matrix user/) }) @@ -193,7 +130,7 @@ test("api invite: can invite with valid nonce", async t => { return {membership: "leave"} }, async inviteToRoom(roomID, mxidToInvite, mxid) { - t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") + t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") called++ }, async setUserPowerCascade(roomID, mxid, power) { @@ -226,7 +163,7 @@ test("api invite: can invite to a moderated guild", async t => { router.test("post", `/api/invite`, { body: { mxid: "@cadence:cadence.moe", - permissions: "admin", + permissions: "default", guild_id: "112760669178241024" }, sessionData: { @@ -238,11 +175,11 @@ test("api invite: can invite to a moderated guild", async t => { throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "Event not found or something"}) }, async inviteToRoom(roomID, mxidToInvite, mxid) { - t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") + t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") called++ }, async setUserPowerCascade(roomID, mxid, power) { - t.equal(power, 100) // moderator + t.equal(power, 0) called++ } } @@ -268,10 +205,14 @@ test("api invite: does not reinvite joined users", async t => { async getStateEvent(roomID, type, key) { called++ return {membership: "join"} + }, + async setUserPowerCascade(roomID, mxid, power) { + t.equal(power, 0) + called++ } } }) ) t.notOk(error) - t.equal(called, 1) + t.equal(called, 2) }) diff --git a/src/web/routes/info.js b/src/web/routes/info.js deleted file mode 100644 index 0ccdeca..0000000 --- a/src/web/routes/info.js +++ /dev/null @@ -1,65 +0,0 @@ -// @ts-check - -const {z} = require("zod") -const {defineEventHandler, getValidatedQuery, H3Event} = require("h3") -const {as, from, sync, select} = require("../../passthrough") - -/** @type {import("../../m2d/converters/utils")} */ -const mUtils = sync.require("../../m2d/converters/utils") - -/** - * @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_channel", "message_id").join("channel_room", "channel_id").where({message_id}) - .select("event_id", "event_type", "event_subtype", "part", "reaction_part", "room_id", "source").and("ORDER BY part ASC, reaction_part DESC").all() - - if (metadatas.length === 0) { - return new Response("Message not found", {status: 404, statusText: "Not Found"}) - } - - const events = await Promise.all(metadatas.map(metadata => - api.getEvent(metadata.room_id, metadata.event_id).then(raw => ({ - metadata: Object.assign({sender: raw.sender}, metadata), - 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 - if (matrix_author.avatar_url) matrix_author.avatar_url = mUtils.getPublicUrlForMxc(matrix_author.avatar_url) - else 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 deleted file mode 100644 index 28dac3b..0000000 --- a/src/web/routes/info.test.js +++ /dev/null @@ -1,219 +0,0 @@ -// @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", - 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", - 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", - 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", - sender: "@_ooye_accavish:cadence.moe", - source: 1 - }, - raw: raw2 - }] - }) - t.equal(called, 2) -}) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index c5f404e..e63c01b 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -1,121 +1,34 @@ // @ts-check const {z} = require("zod") -const {defineEventHandler, createError, readValidatedBody, setResponseHeader, H3Event} = require("h3") +const {defineEventHandler, useSession, createError, readValidatedBody} = 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/mreq")} */ -const mreq = sync.require("../../matrix/mreq") +/** @type {import("../../d2m/actions/create-space")} */ +const createSpace = sync.require("../../d2m/actions/create-space") +/** @type {import("../../d2m/actions/create-room")} */ +const createRoom = sync.require("../../d2m/actions/create-room") const {reg} = require("../../matrix/read-registration") -/** - * @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") -} +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") 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() }) } -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 - const inviteType = select("invite", "type", {mxid: session.data.mxid, room_id: spaceID}).pluck().get() - if (inviteType !== "m.space") throw createError({status: 403, message: "Forbidden", data: "You personally must invite OOYE to that space on Matrix"}) - - // Check they are not already bridged - const existing = select("guild_space", "guild_id", {}, "WHERE guild_id = ? OR space_id = ?").get(guildID, spaceID) - if (existing) throw createError({status: 400, message: "Bad Request", data: `Guild ID ${guildID} or space ID ${spaceID} are already bridged and cannot be reused`}) - - // Check space exists and bridge is joined - try { - await api.joinRoom(parsedBody.space_id) - } catch (e) { - throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) - } - - // Check bridge has PL 100 - const me = `@${reg.sender_localpart}:${reg.ooye.server_name}` - /** @type {Ty.Event.M_Power_Levels?} */ - let powerLevelsStateContent = null - try { - powerLevelsStateContent = await api.getStateEvent(spaceID, "m.room.power_levels", "") - } catch (e) {} - const selfPowerLevel = powerLevelsStateContent?.users?.[me] ?? powerLevelsStateContent?.users_default ?? 0 - if (selfPowerLevel < (powerLevelsStateContent?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix space"}) - - // Check inviting user is a moderator in the space - const invitingPowerLevel = powerLevelsStateContent?.users?.[session.data.mxid] ?? powerLevelsStateContent?.users_default ?? 0 - if (invitingPowerLevel < (powerLevelsStateContent?.state_default ?? 50)) throw createError({status: 403, message: "Forbidden", data: `You need to be at least power level 50 (moderator) in the target Matrix space to set up OOYE, but you are currently power level ${invitingPowerLevel}.`}) - - // 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) + const session = await useSession(event, {password: reg.as_token}) // 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"}) + if (!(session.data.managedGuilds || []).includes(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) @@ -126,91 +39,25 @@ as.router.post("/api/link", defineEventHandler(async event => { 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`}) + const row = from("channel_room").select("channel_id", "room_id").and("WHERE channel_id = ? OR room_id = ?").get(parsedBody.discord, parsedBody.matrix) + if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} and room ID ${row.room_id} are already bridged and cannot be reused`}) - // Check room is part of the guild's space - let found = false - for await (const room of api.generateFullHierarchy(spaceID)) { - if (room.room_id === parsedBody.matrix && !room.room_type) { - found = true - break - } - } - if (!found) 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 and bridge has PL 100 + const self = `@${reg.sender_localpart}:${reg.ooye.server_name}` + /** @type {Ty.Event.M_Room_Member} */ + const memberEvent = await api.getStateEvent(parsedBody.matrix, "m.room.member", self) + if (memberEvent.membership !== "join") throw createError({status: 400, message: "Bad Request", data: "Matrix room does not exist"}) + /** @type {Ty.Event.M_Power_Levels} */ + const powerLevelsStateContent = await api.getStateEvent(parsedBody.matrix, "m.room.power_levels", "") + const selfPowerLevel = powerLevelsStateContent.users?.[self] || powerLevelsStateContent.users_default || 0 + if (selfPowerLevel < (powerLevelsStateContent.state_default || 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"}) - // Check room exists and bridge is joined - try { - await api.joinRoom(parsedBody.matrix) - } catch (e) { - throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) - } - - // Check bridge has PL 100 - const me = `@${reg.sender_localpart}:${reg.ooye.server_name}` - /** @type {Ty.Event.M_Power_Levels?} */ - let powerLevelsStateContent = null - try { - powerLevelsStateContent = await api.getStateEvent(parsedBody.matrix, "m.room.power_levels", "") - } catch (e) {} - const selfPowerLevel = powerLevelsStateContent?.users?.[me] ?? powerLevelsStateContent?.users_default ?? 0 - if (selfPowerLevel < (powerLevelsStateContent?.state_default ?? 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"}) - - // Insert database entry, but keep the room's existing properties if they are set - const nick = await api.getStateEvent(parsedBody.matrix, "m.room.name", "").then(content => content.name || null).catch(() => null) - const avatar = await api.getStateEvent(parsedBody.matrix, "m.room.avatar", "").then(content => content.url || null).catch(() => null) - const topic = await api.getStateEvent(parsedBody.matrix, "m.room.topic", "").then(content => content.topic || null).catch(() => null) - db.prepare("INSERT INTO channel_room (channel_id, room_id, name, guild_id, nick, custom_avatar, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, parsedBody.matrix, channel.name, guildID, nick, avatar, topic) + // Insert database entry + db.prepare("INSERT INTO channel_room (channel_id, room_id, name, guild_id) VALUES (?, ?, ?, ?)").run(parsedBody.discord, parsedBody.matrix, channel.name, guildID) // 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) - const createRoom = getCreateRoom(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"}) - - // Check that the channel (if it exists) is part of this guild - /** @type {any} */ - let channel = discord.channels.get(channel_id) - if (channel) { - if (!("guild_id" in channel) || channel.guild_id !== guild_id) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not part of guild ${guild_id}`}) - } else { - // Otherwise, if the channel isn't cached, it must have been deleted. - // There's no other authentication here - it's okay for anyone to unlink a deleted channel just by knowing its ID. - channel = {id: channel_id} - } - - // Check channel is currently bridged - const row = select("channel_room", "channel_id", {channel_id: channel_id}).get() - if (!row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${channel_id} is not currently bridged`}) - - // Do it - await createRoom.unbridgeDeletedChannel(channel, guild_id) - - setResponseHeader(event, "HX-Refresh", "true") return null // 204 })) diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js deleted file mode 100644 index 0d8d366..0000000 --- a/src/web/routes/link.test.js +++ /dev/null @@ -1,632 +0,0 @@ -// @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, "M_FORBIDDEN - not allowed to join I guess") - t.equal(called, 1) -}) - -test("web link space: check that OOYE has PL 100 (not missing)", async t => { - let called = 0 - const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { - sessionData: { - managedGuilds: ["665289423482519565"], - mxid: "@cadence:cadence.moe" - }, - body: { - space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", - guild_id: "665289423482519565" - }, - api: { - async joinRoom(roomID) { - called++ - return roomID - }, - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(type, "m.room.power_levels") - throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "what if I told you that power levels never existed"}) - } - } - })) - t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix space") - t.equal(called, 2) -}) - -test("web link space: check that OOYE has PL 100 (not users_default)", async t => { - let called = 0 - const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { - sessionData: { - managedGuilds: ["665289423482519565"], - mxid: "@cadence:cadence.moe" - }, - body: { - space_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", - guild_id: "665289423482519565" - }, - api: { - async joinRoom(roomID) { - called++ - return roomID - }, - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - return {} - } - } - })) - t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix space") - t.equal(called, 2) -}) - -test("web link space: check that OOYE has PL 100 (not 50)", async t => { - let called = 0 - const [error] = await tryToCatch(() => router.test("post", "/api/link-space", { - 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}} - } - } - })) - t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix space") - t.equal(called, 2) -}) - -test("web link space: check that 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}} - } - } - })) - t.equal(error.data, "You need to be at least power level 50 (moderator) in the target Matrix space to set up OOYE, but you are currently power level 0.") - t.equal(called, 2) -}) - -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: {"@_ooye_bot:cadence.moe": 100, "@cadence:cadence.moe": 50}} - } - } - }) - t.equal(called, 2) - - // 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", 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, "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 (event missing)", async t => { - let called = 0 - const [error] = await tryToCatch(() => router.test("post", "/api/link", { - sessionData: { - managedGuilds: ["665289423482519565"] - }, - body: { - discord: "665310973967597573", - matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - guild_id: "665289423482519565" - }, - api: { - async joinRoom(roomID) { - called++ - return roomID - }, - async *generateFullHierarchy(spaceID) { - called++ - t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - yield { - room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", - children_state: {}, - guest_can_join: false, - num_joined_members: 2 - } - /* c8 ignore next */ - }, - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - t.equal(type, "m.room.power_levels") - t.equal(key, "") - throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "what if I told you there's no such thing as power levels"}) - } - } - })) - t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room") - t.equal(called, 3) -}) - -test("web link room: check that bridge has PL 100 in target room (users default)", async t => { - 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} - } - } - })) - t.equal(error.data, "OOYE needs power level 100 (admin) in the target Matrix room") - t.equal(called, 3) -}) - -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 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, 8) -}) - -// ***** - -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 unbridgeDeletedChannel 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 unbridgeDeletedChannel(channel) { - called++ - t.equal(channel.id, "665310973967597573") - } - } - }) - t.equal(called, 1) -}) - -test("web unlink room: successfully calls unbridgeDeletedChannel 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 unbridgeDeletedChannel(channel) { - called++ - t.equal(channel.id, "489237891895768942") - } - } - }) - t.equal(called, 1) -}) - -test("web unlink room: checks that the channel is bridged", async t => { - 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") -}) diff --git a/src/web/routes/log-in-with-matrix.js b/src/web/routes/log-in-with-matrix.js deleted file mode 100644 index 574c312..0000000 --- a/src/web/routes/log-in-with-matrix.js +++ /dev/null @@ -1,123 +0,0 @@ -// @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) - } - } - - // Check if we have an existing DM - let roomID = select("direct", "room_id", {mxid}).pluck().get() - if (roomID) { - // Check that the person is/still in the room - try { - var member = await api.getStateEvent(roomID, "m.room.member", mxid) - } catch (e) {} - - // Invite them back to the room if needed - if (!member || member.membership === "leave") { - await api.inviteToRoom(roomID, mxid) - } - } - - // No existing DM, create a new room and invite - else { - roomID = await api.createRoom({ - invite: [mxid], - is_direct: true, - preset: "trusted_private_chat" - }) - // Store the newly created room in account data (Matrix doesn't do this for us automatically, sigh...) - db.prepare("REPLACE INTO direct (mxid, room_id) VALUES (?, ?)").run(mxid, roomID) - } - - const 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 deleted file mode 100644 index bc9c7e0..0000000 --- a/src/web/routes/log-in-with-matrix.test.js +++ /dev/null @@ -1,169 +0,0 @@ -// @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 when there is no existing dm room", async t => { - const event = {} - let called = 0 - await router.test("post", "/api/log-in-with-matrix", { - body: { - mxid: "@cadence:cadence.moe" - }, - api: { - async createRoom() { - called++ - return "!created:cadence.moe" - }, - async sendEvent(roomID, type, content) { - called++ - t.equal(roomID, "!created:cadence.moe") - t.equal(type, "m.room.message") - token = content.body.match(/log-in-with-matrix\?token=([a-f0-9-]+)/)[1] - 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/) -}) - -test("log in with matrix: reuses room from direct", async t => { - const event = {} - let called = 0 - await router.test("post", "/api/log-in-with-matrix", { - body: { - mxid: "@user1:example.org" - }, - api: { - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!existing:cadence.moe") - t.equal(type, "m.room.member") - t.equal(key, "@user1:example.org") - return {membership: "join"} - }, - async sendEvent(roomID) { - called++ - t.equal(roomID, "!existing:cadence.moe") - return "" - } - }, - event - }) - t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/) - t.equal(called, 2) -}) - -test("log in with matrix: reuses room from direct, reinviting if user has left", async t => { - const event = {} - let called = 0 - await router.test("post", "/api/log-in-with-matrix", { - body: { - mxid: "@user2:example.org" - }, - api: { - async getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!existing:cadence.moe") - t.equal(type, "m.room.member") - t.equal(key, "@user2:example.org") - throw new MatrixServerError({errcode: "M_NOT_FOUND"}) - }, - async inviteToRoom(roomID, mxid) { - called++ - t.equal(roomID, "!existing:cadence.moe") - t.equal(mxid, "@user2:example.org") - }, - async sendEvent(roomID) { - called++ - t.equal(roomID, "!existing:cadence.moe") - return "" - } - }, - event - }) - t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/) - t.equal(called, 3) -}) - -// ***** third request ***** - - -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 80765d6..12c991d 100644 --- a/src/web/routes/oauth.js +++ b/src/web/routes/oauth.js @@ -2,15 +2,14 @@ const {z} = require("zod") const {randomUUID} = require("crypto") -const {defineEventHandler, getValidatedQuery, sendRedirect, createError} = require("h3") -const {SnowTransfer, tokenless} = require("snowtransfer") +const {defineEventHandler, getValidatedQuery, sendRedirect, getQuery, useSession, createError} = require("h3") +const {SnowTransfer} = require("snowtransfer") const DiscordTypes = require("discord-api-types/v10") +const fetch = require("node-fetch") const getRelativePath = require("get-relative-path") -const {discord, as, db, sync} = require("../../passthrough") +const {as, db} = require("../../passthrough") const {id} = require("../../../addbot") -/** @type {import("../auth")} */ -const auth = sync.require("../auth") const {reg} = require("../../matrix/read-registration") const redirect_uri = `${reg.ooye.bridge_origin}/oauth` @@ -27,25 +26,23 @@ const schema = { token: z.object({ token_type: z.string(), access_token: z.string(), - expires_in: z.coerce.number(), + expires_in: z.number({coerce: true}), refresh_token: z.string(), scope: z.string() }) } as.router.get("/oauth", defineEventHandler(async event => { - const session = await auth.useSession(event) + const session = await useSession(event, {password: reg.as_token}) let scope = "guilds" - 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}) - } + 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() { @@ -61,18 +58,28 @@ as.router.get("/oauth", defineEventHandler(async event => { if (!savedState) throw createError({status: 400, message: "Missing state", data: "Missing saved state parameter. Please try again, and make sure you have cookies enabled."}) if (savedState != parsedQuery.data.state) return tryAgain() - const oauthResult = await tokenless.getOauth2Token(id, redirect_uri, reg.ooye.discord_client_secret, parsedQuery.data.code) - const parsedToken = schema.token.safeParse(oauthResult) - if (!parsedToken.success) { - throw createError({status: 502, message: "Invalid token response", data: `Discord completed OAuth, but returned this instead of an OAuth access token: ${JSON.stringify(oauthResult)}`}) + const 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 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.data.access_token.split(".")[0], "base64").toString() const client = new SnowTransfer(`Bearer ${parsedToken.data.access_token}`) try { const guilds = await client.user.getGuilds() var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id) - await session.update({managedGuilds, userID, state: undefined}) + await session.update({managedGuilds}) } catch (e) { throw createError({status: 502, message: "API call failed", data: e.message}) } @@ -80,8 +87,7 @@ as.router.get("/oauth", defineEventHandler(async event => { // 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) + db.prepare("REPLACE INTO guild_active (guild_id, autocreate) VALUES (?, ?)").run(parsedQuery.data.guild_id, +!session.data.selfService) } if (parsedQuery.data.guild_id) { diff --git a/src/web/routes/password.js b/src/web/routes/password.js deleted file mode 100644 index e1dd299..0000000 --- a/src/web/routes/password.js +++ /dev/null @@ -1,21 +0,0 @@ -// @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/server.js b/src/web/server.js index 7c8ed3e..9373947 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -1,25 +1,21 @@ // @ts-check -const assert = require("assert") const fs = require("fs") const {join} = require("path") const h3 = require("h3") -const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHeader, handleCacheHeaders} = h3 +const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHeader, setResponseStatus, useSession, getQuery, handleCacheHeaders} = 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") /** @type {import("./pug-sync")} */ const pugSync = sync.require("./pug-sync") -/** @type {import("../m2d/converters/utils")} */ -const mUtils = sync.require("../m2d/converters/utils") const {id} = require("../../addbot") // Pug -pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, mUtils, icons, reg: reg.reg}) +pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, icons}) pugSync.createRoute(as.router, "/", "home.pug") pugSync.createRoute(as.router, "/ok", "ok.pug") @@ -29,11 +25,8 @@ 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") // Files @@ -41,8 +34,8 @@ 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")) } @@ -56,12 +49,12 @@ as.router.get("/static/stacks.min.css", defineEventHandler({ } })) -as.router.get("/static/htmx.js", defineEventHandler({ +as.router.get("/static/htmx.min.js", defineEventHandler({ onBeforeResponse: compressResponse, handler: async event => { handleCacheHeaders(event, {maxAge: 86400}) defaultContentType(event, "text/javascript") - return fs.promises.readFile(require.resolve("htmx.org/dist/htmx.js"), "utf-8") + return fs.promises.readFile(join(__dirname, "static", "htmx.min.js"), "utf-8") } })) diff --git a/src/web/server.test.js b/src/web/server.test.js index 6ed3535..f02a7a6 100644 --- a/src/web/server.test.js +++ b/src/web/server.test.js @@ -1,18 +1,16 @@ // @ts-check -const streamWeb = require("stream/web") -const {test} = require("../../test/web") +const {test} = require("supertape") 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/) + t.match(await router.test("get", "/", {}), /Add the bot to your Discord server./) }) test("web server: can get htmx", async t => { - t.match(await router.test("get", "/static/htmx.js", {}), /htmx =/) + t.match(await router.test("get", "/static/htmx.min.js", {}), /htmx=/) }) test("web server: can get css", async t => { @@ -30,8 +28,5 @@ test("web server: compresses static resources", async t => { "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") + t.ok(content instanceof ReadableStream) }) diff --git a/src/web/static/htmx.min.js b/src/web/static/htmx.min.js new file mode 100644 index 0000000..c11fbbd --- /dev/null +++ b/src/web/static/htmx.min.js @@ -0,0 +1 @@ +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;e 0){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(n 0}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;t 0){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;e 0){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;e 0){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;e Q.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;e n.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;e 0?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;t 0){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 ca6212b..4ee547b 100755 --- a/start.js +++ b/start.js @@ -1,7 +1,6 @@ #!/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") @@ -10,7 +9,7 @@ const {reg} = require("./src/matrix/read-registration") const passthrough = require("./src/passthrough") const db = new sqlite("ooye.db") -const sync = new HeatSync({watchFunction: fs.watchFile}) +const sync = new HeatSync() Object.assign(passthrough, {sync, db}) diff --git a/test/data.js b/test/data.js index a8ff8a8..b92ae1b 100644 --- a/test/data.js +++ b/test/data.js @@ -18,85 +18,6 @@ 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: { @@ -105,7 +26,7 @@ module.exports = { "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/!jjmvBegULiLucuWEHU:cadence.moe": { + "m.space.parent/!jjWAGMeQdNrVZSSfvz:cadence.moe": { via: ["cadence.moe"], canonical: true }, @@ -113,18 +34,13 @@ module.exports = { join_rule: "restricted", allow: [{ type: "m.room_membership", - room_id: "!jjmvBegULiLucuWEHU:cadence.moe" + room_id: "!jjWAGMeQdNrVZSSfvz: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 }, @@ -153,7 +69,6 @@ module.exports = { } }, guild: { - /** @type {DiscordTypes.APIGuild} */ // @ts-ignore general: { owner_id: "112760500130975744", premium_tier: 3, @@ -255,25 +170,6 @@ 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, @@ -356,721 +252,6 @@ module.exports = { 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: { @@ -1088,19 +269,6 @@ 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: { @@ -1836,93 +1004,6 @@ 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, @@ -2333,95 +1414,6 @@ 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, @@ -3387,79 +2379,6 @@ module.exports = { } } ] - }, - 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" } }, pk_message: { @@ -3804,7 +2723,6 @@ module.exports = { }, webhook_id: "1109360903096369153" }, - reply_with_only_embed: { type: 19, tts: false, @@ -4556,64 +3474,6 @@ module.exports = { edited_timestamp: null, flags: 0, components: [] - }, - 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_update: { @@ -5694,250 +4554,5 @@ 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 b31f2c3..38fed25 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -1,16 +1,13 @@ BEGIN TRANSACTION; -INSERT INTO guild_active (guild_id, autocreate) VALUES -('112760669178241024', 1), -('66192955777486848', 1), -('665289423482519565', 0); - INSERT INTO guild_space (guild_id, space_id, privacy_level) VALUES -('112760669178241024', '!jjmvBegULiLucuWEHU:cadence.moe', 0); +('112760669178241024', '!jjWAGMeQdNrVZSSfvz:cadence.moe', 0); + +INSERT INTO guild_active (guild_id, autocreate) VALUES +('112760669178241024', 1); INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar) VALUES ('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL, NULL), -('687028734322147344', '!fGgIymcYWOqjbSRUdV:cadence.moe', 'slow-news-day', NULL, NULL, NULL), ('497161350934560778', '!CzvdIdUQXgUjDVKxeU:cadence.moe', 'amanda-spam', NULL, NULL, NULL), ('160197704226439168', '!hYnGGlPHlbujVVfktC:cadence.moe', 'the-stanley-parable-channel', 'bots', NULL, NULL), ('1100319550446252084', '!BnKuBPCvyfOkhcUjEu:cadence.moe', 'worm-farm', NULL, NULL, NULL), @@ -19,28 +16,27 @@ INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom ('122155380120748034', '!cqeGDbPiMFAhLsqqqq:cadence.moe', 'cadences-mind', 'coding', NULL, NULL), ('176333891320283136', '!qzDBLKlildpzrrOnFZ:cadence.moe', '🌈丨davids-horse_she-took-the-kids', 'wonderland', NULL, 'mxc://cadence.moe/EVvrSkKIRONHjtRJsMLmHWLS'), ('489237891895768942', '!tnedrGVYKFNUdnegvf:tchncs.de', 'ex-room-doesnt-exist-any-more', NULL, NULL, NULL), -('1160894080998461480', '!TqlyQmifxGUggEmdBN:cadence.moe', 'ooyexperiment', NULL, NULL, NULL), -('1161864271370666075', '!mHmhQQPwXNananMUqq:cadence.moe', 'updates', NULL, NULL, NULL); +('1160894080998461480', '!TqlyQmifxGUggEmdBN:cadence.moe', 'ooyexperiment', NULL, NULL, NULL); -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 (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 sim_proxy (user_id, proxy_owner_id, displayname) VALUES +('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺'); 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 sim_proxy (user_id, proxy_owner_id, displayname) VALUES -('43d378d5-1183-47dc-ab3c-d14e21c3fe58', '196188877885538304', 'Azalea &flwr; 🌺'); - INSERT INTO message_channel (message_id, channel_id) VALUES ('1106366167788044450', '122155380120748034'), ('1106366167788044451', '122155380120748034'), @@ -69,11 +65,7 @@ INSERT INTO message_channel (message_id, channel_id) VALUES ('1273743950028607530', '1100319550446252084'), ('1278002262400176128', '1100319550446252084'), ('1278001833876525057', '1100319550446252084'), -('1191567971970191490', '176333891320283136'), -('1144874214311067708', '687028734322147344'), -('1339000288144658482', '176333891320283136'), -('1381212840957972480', '112760669178241024'), -('1401760355339862066', '112760669178241024'); +('1191567971970191490', '176333891320283136'); 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), @@ -113,11 +105,7 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part ('$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), -('$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); +('$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE', 'm.room.message', 'm.text', '1191567971970191490', 0, 0, 1); INSERT INTO file (discord_url, mxc_url) VALUES ('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'), @@ -151,19 +139,20 @@ INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES ('288858540888686602', 'upstinky', 0, 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO'); 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), -('!kLRqKKUQXcibIMtOpl:cadence.moe', '@test_auto_invite:example.org', NULL, NULL, 0), +('!BpMdOUkWWhFxmTrENV:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'malformed mxc', 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), -('!BnKuBPCvyfOkhcUjEu:cadence.moe', '@ami:the-apothecary.club', 'Ami (she/her)', NULL, 0), +('!maggESguZBqGBZtSnr:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0), ('!CzvdIdUQXgUjDVKxeU:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', 'mxc://cadence.moe/azCAhThKTojXSZJRoWwZmhvU', 0), -('!TqlyQmifxGUggEmdBN:cadence.moe', '@Milan:tchncs.de', 'Milan', NULL, 0), +('!cBxtVRxDlZvSVhJXVK: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), -('!iSyXgNxQcEuXoXpsSn:pussthecat.org', '@austin:tchncs.de', 'Austin Huang', 'mxc://tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e', 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); INSERT INTO reaction (hashed_event_id, message_id, encoded_emoji) VALUES (5162930312280790092, '1141501302736695317', '%F0%9F%90%88'); @@ -174,22 +163,13 @@ INSERT INTO member_power (mxid, room_id, power_level) VALUES INSERT INTO lottie (sticker_id, mxc_url) VALUES ('860171525772279849', 'mxc://cadence.moe/ZtvvVbwMIdUZeovWVyGVFCeR'); -INSERT INTO auto_emoji (name, emoji_id) VALUES -('L1', '1144820033948762203'), -('L2', '1144820084079087647'); +INSERT INTO auto_emoji (name, emoji_id, guild_id) VALUES +('L1', '1144820033948762203', '529176156398682115'), +('L2', '1144820084079087647', '529176156398682115'), +('_', '_', '529176156398682115'); 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'); - COMMIT; diff --git a/test/test.js b/test/test.js index 3695a84..591b64b 100644 --- a/test/test.js +++ b/test/test.js @@ -2,12 +2,15 @@ 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, extend} = require("supertape") +const {test} = 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") @@ -20,41 +23,31 @@ reg.ooye.server_name = "cadence.moe" 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" 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.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.general.id, [data.channel.general.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] + }] ]) } @@ -89,8 +82,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not 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 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"}, @@ -128,19 +120,10 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not require("./addbot.test") require("../src/db/orm.test") - require("../src/web/server.test") - require("../src/web/routes/download-discord.test") - require("../src/web/routes/download-matrix.test") - require("../src/web/routes/guild.test") - require("../src/web/routes/guild-settings.test") - require("../src/web/routes/info.test") - require("../src/web/routes/link.test") - require("../src/web/routes/log-in-with-matrix.test") require("../src/discord/utils.test") require("../src/matrix/kstate.test") require("../src/matrix/api.test") require("../src/matrix/file.test") - require("../src/matrix/mreq.test") require("../src/matrix/read-registration.test") require("../src/matrix/txnid.test") require("../src/d2m/actions/create-room.test") @@ -156,10 +139,8 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not 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/emoji.test") require("../src/m2d/converters/utils.test") require("../src/m2d/converters/emoji-sheet.test") require("../src/discord/interactions/invite.test") @@ -167,4 +148,8 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not require("../src/discord/interactions/permissions.test") require("../src/discord/interactions/privacy.test") require("../src/discord/interactions/reactions.test") + require("../src/web/server.test") + require("../src/web/routes/download-discord.test") + require("../src/web/routes/download-matrix.test") + require("../src/web/routes/guild.test") })() diff --git a/test/web.js b/test/web.js index 09af95b..e22a97c 100644 --- a/test/web.js +++ b/test/web.js @@ -2,39 +2,6 @@ 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() { @@ -43,7 +10,7 @@ class Router { for (const method of ["get", "post", "put", "patch", "delete"]) { this[method] = function(url, handler) { const key = `${method} ${url}` - this.routes.set(key, handler) + this.routes.set(`${key}`, handler) } } } @@ -51,9 +18,9 @@ class Router { /** * @param {string} method * @param {string} inputUrl - * @param {{event?: any, params?: any, body?: any, sessionData?: any, api?: Partial , snow?: {[k in keyof SnowTransfer]?: Partial }, createRoom?: Partial , createSpace?: Partial , headers?: any}} [options] + * @param {{event?: any, params?: any, body?: any, sessionData?: any, api?: Partial , snow?: {[k in keyof SnowTransfer]?: Partial }, headers?: any}} [options] */ - async test(method, inputUrl, options = {}) { + test(method, inputUrl, options = {}) { const url = new URL(inputUrl, "http://a") const key = `${method} ${options.route || url.pathname}` /* c8 ignore next */ @@ -71,42 +38,32 @@ class Router { 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, - params: options.params, - snow: options.snow, - createRoom: options.createRoom, - createSpace: options.createSpace, - sessions: { - h3: { - id: "h3", - createdAt: 0, - data: options.sessionData || {} - } + return this.routes.get(key)(Object.assign(event, { + method: method.toUpperCase(), + path: `${url.pathname}${url.search}`, + _requestBody: options.body, + node: { + req, + res: new http.ServerResponse(req) + }, + context: { + api: options.api, + params: options.params, + snow: options.snow, + 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} +passthrough.as = {router} module.exports.router = router -module.exports.test = test