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]
", + "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: { diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index ae7ea1e..1d6288a 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -33,8 +33,10 @@ 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 + const username = message.mentions?.find(ment => ment.id === node.id)?.username + || message.referenced_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}` @@ -204,7 +206,7 @@ async function attachmentToEvent(mentions, attachment) { * - 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")}} di simple-as-nails dependency injection for the matrix API + * @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer}} di simple-as-nails dependency injection for the matrix API */ async function messageToEvent(message, guild, options = {}, di) { const events = [] @@ -401,7 +403,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}) // Register the custom emoji if needed + return emojiToKey.emojiToKey({id, name, animated}, message.id) // Register the custom emoji if needed })) async function transformParsedVia(parsed) { @@ -412,8 +414,10 @@ async function messageToEvent(message, guild, options = {}, di) { node.via = await getViaServersMemo(node.row.room_id) } } - if (Array.isArray(node.content)) { - await transformParsedVia(node.content) + ;for (const maybeChildNodesArray of [node, node.content, node.items]) { + if (Array.isArray(maybeChildNodesArray)) { + await transformParsedVia(maybeChildNodesArray) + } } } return parsed @@ -477,14 +481,7 @@ 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 repliedToHtml = markdown.toHTML(repliedToContent, { - discordCallback: getDiscordParseCallbacks(message, guild, true) - }) - const repliedToBody = markdown.toHTML(repliedToContent, { - discordCallback: getDiscordParseCallbacks(message, guild, false), - discordOnly: true, - escapeHTML: false, - }) + const {body: repliedToBody, html: repliedToHtml} = await transformContent(repliedToContent) if (repliedToEventRow) { // Generate a reply pointing to the Matrix event we found html = `🎞️ https://media.tenor.com/Bz5pfRIu81oAAAPo/get-real.mp4
In reply to ${repliedToUserHtml}` @@ -496,19 +493,11 @@ async function messageToEvent(message, guild, options = {}, di) { } 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 dateDifference = new Date(message.timestamp).getTime() - new Date(message.referenced_message.timestamp).getTime() - const oneHour = 60 * 60 * 1000 - if (dateDifference < oneHour) { - var dateDisplay = "n" - } else if (dateDifference < 25 * oneHour) { - var dateDisplay = ` ${Math.floor(dateDifference / oneHour)}-hour-old` - } else { - var dateDisplay = ` ${Math.round(dateDifference / (24 * oneHour))}-day-old` - } - html = `In reply to a${dateDisplay} unbridged message from ${repliedToDisplayName}:` + const dateDisplay = dUtils.howOldUnbridgedMessage(message.referenced_message.timestamp, message.timestamp) + html = `In reply to ${dateDisplay} from ${repliedToDisplayName}:` + `` + html - body = (`In reply to a${dateDisplay} unbridged message:\n${repliedToDisplayName}: ` + body = (`In reply to ${dateDisplay}:\n${repliedToDisplayName}: ` + repliedToBody).split("\n").map(line => "> " + line).join("\n") + "\n\n" + body } @@ -615,6 +604,49 @@ 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 = `
${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))) @@ -640,7 +672,7 @@ async function messageToEvent(message, guild, options = {}, di) { const rep = new mxUtils.MatrixStringBuilder() // Provider - if (embed.provider?.name) { + if (embed.provider?.name && embed.provider.name !== "Tenor") { if (embed.provider.url) { rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`${embed.provider.name}`) } else { diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 3e7bcd4..fc933e3 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -532,6 +532,43 @@ 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: { @@ -1165,3 +1202,174 @@ test("message2event: don't scan forwarded messages for mentions", async t => { } ]) }) + +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: ` In reply to okay 🤍 yay 🤍' + + '
@extremity you owe me $30`, + "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 047bb9f..3e890ea 100644 --- a/src/d2m/converters/pins-to-list.js +++ b/src/d2m/converters/pins-to-list.js @@ -4,16 +4,28 @@ const {select} = require("../../passthrough") /** * @param {import("discord-api-types/v10").RESTGetAPIChannelPinsResult} pins + * @param {{"m.room.pinned_events/"?: {pinned?: string[]}}} kstate */ -function pinsToList(pins) { +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 + }) + /** @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) result.push(eventID) + if (eventID && !alreadyPinned.includes(eventID)) result.push(eventID) } result.reverse() - return result + return alreadyPinned.concat(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 7ee89b6..d0657cb 100644 --- a/src/d2m/converters/pins-to-list.test.js +++ b/src/d2m/converters/pins-to-list.test.js @@ -3,10 +3,59 @@ 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/discord-client.js b/src/d2m/discord-client.js index f65acf8..b05d48f 100644 --- a/src/d2m/discord-client.js +++ b/src/d2m/discord-client.js @@ -62,9 +62,6 @@ 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 c9424a4..017d50e 100644 --- a/src/d2m/discord-packets.js +++ b/src/d2m/discord-packets.js @@ -157,59 +157,17 @@ const utils = { } // Event dispatcher for OOYE bridge operations - if (listen === "full") { + if (listen === "full" && message.t) { try { - 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") { - // @ts-ignore - 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") { + 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 === "PRESENCE_UPDATE") { - eventDispatcher.onPresenceUpdate(client, 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 fdb6c93..1698317 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -2,7 +2,6 @@ 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")}) */ @@ -27,21 +26,18 @@ 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") -/** @type {any} */ // @ts-ignore bad types from semaphore -const Semaphore = require("@chriscdn/promise-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 = { @@ -51,48 +47,14 @@ 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 - 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) - } - } + if (gatewayMessage.t === "TYPING_START") return - 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")}`, `
- 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
`) - } - 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"] - } - }) + await matrixEventDispatcher.sendError(roomID, "Discord", gatewayMessage.t, e, gatewayMessage) }, /** @@ -146,13 +108,24 @@ 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 simulatedGatewayDispatchData = { + 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, { guild_id: guild.id, + member: members.get(message.author.id), + // @ts-ignore backfill: true, - ...messages[i] - } - await module.exports.onMessageCreate(client, simulatedGatewayDispatchData) + ...message + }) } } }, @@ -199,7 +172,7 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.APIThreadChannel} thread */ - async onThreadCreate(client, thread) { + async THREAD_CREATE(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) @@ -211,7 +184,7 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayGuildUpdateDispatchData} guild */ - async onGuildUpdate(client, guild) { + async GUILD_UPDATE(client, guild) { const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get() if (!spaceID) return await createSpace.syncSpace(guild) @@ -220,19 +193,26 @@ module.exports = { /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread - * @param {boolean} isThread */ - async onChannelOrThreadUpdate(client, channelOrThread, isThread) { + async CHANNEL_UPDATE(client, channelOrThread) { 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 onChannelPinsUpdate(client, data) { + async CHANNEL_PINS_UPDATE(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) @@ -243,7 +223,7 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayChannelDeleteDispatchData} channel */ - async onChannelDelete(client, channel) { + async CHANNEL_DELETE(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() @@ -256,7 +236,7 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageCreateDispatchData} message */ - async onMessageCreate(client, message) { + async MESSAGE_CREATE(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. @@ -286,7 +266,7 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageUpdateDispatchData} data */ - async onMessageUpdate(client, data) { + async MESSAGE_UPDATE(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. @@ -304,7 +284,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.onMessageUpdate, client, data)) return + if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_UPDATE, client, data)) return /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ // @ts-ignore @@ -322,7 +302,7 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data */ - async onReactionAdd(client, data) { + async MESSAGE_REACTION_ADD(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) }, @@ -339,25 +319,25 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageDeleteDispatchData} data */ - async onMessageDelete(client, data) { + async MESSAGE_DELETE(client, data) { speedbump.onMessageDelete(data.id) - if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageDelete, client, data)) return + if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.MESSAGE_DELETE, client, data)) return await deleteMessage.deleteMessage(data) }, - /** + /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayMessageDeleteBulkDispatchData} data */ - async onMessageDeleteBulk(client, data) { - await deleteMessage.deleteMessageBulk(data) - }, + async MESSAGE_DELETE_BULK(client, data) { + await deleteMessage.deleteMessageBulk(data) + }, /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayTypingStartDispatchData} data */ - async onTypingStart(client, data) { + async TYPING_START(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() @@ -370,9 +350,17 @@ module.exports = { /** * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData | DiscordTypes.GatewayGuildStickersUpdateDispatchData} data + * @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData} data */ - async onExpressionsUpdate(client, data) { + async GUILD_EMOJIS_UPDATE(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) }, @@ -380,7 +368,7 @@ module.exports = { * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayPresenceUpdateDispatchData} data */ - onPresenceUpdate(client, 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/migrations/0022-auto-emoji-without-guild.sql b/src/db/migrations/0022-auto-emoji-without-guild.sql new file mode 100644 index 0000000..1d23c0d --- /dev/null +++ b/src/db/migrations/0022-auto-emoji-without-guild.sql @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..e42e4e1 --- /dev/null +++ b/src/db/migrations/0023-add-original-encoding-to-reaction.sql @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..94dc4ae --- /dev/null +++ b/src/db/migrations/0024-add-direct.sql @@ -0,0 +1,9 @@ +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 c38dc35..79fd501 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -1,4 +1,9 @@ export type Models = { + auto_emoji: { + name: string + emoji_id: string + } + channel_room: { channel_id: string room_id: string @@ -14,6 +19,18 @@ export type Models = { custom_topic: number } + direct: { + mxid: string + room_id: string + } + + emoji: { + emoji_id: string + name: string + animated: number + mxc_url: string + } + event_message: { event_id: string message_id: string @@ -55,6 +72,10 @@ export type Models = { mxc_url: string } + media_proxy: { + permitted_hash: number + } + member_cache: { room_id: string mxid: string @@ -99,27 +120,11 @@ 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 - } - - auto_emoji: { - name: string - emoji_id: string - guild_id: string - } - - media_proxy: { - permitted_hash: number + original_encoding: string | null } } diff --git a/src/discord/utils.js b/src/discord/utils.js index dea05ae..963f0b8 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -136,6 +136,24 @@ 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 @@ -145,3 +163,4 @@ 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 6eb12e0..277c475 100644 --- a/src/m2d/actions/add-reaction.js +++ b/src/m2d/actions/add-reaction.js @@ -31,10 +31,14 @@ async function addReaction(event) { // 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 } - db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji) VALUES (?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding) + db.prepare("REPLACE INTO reaction (hashed_event_id, message_id, encoded_emoji, original_encoding) VALUES (?, ?, ?, ?)").run(utils.getEventIDHash(event.event_id), messageID, discordPreferredEncoding, key) } module.exports.addReaction = addReaction diff --git a/src/m2d/actions/send-event.js b/src/m2d/actions/send-event.js index edeb156..6b4eb26 100644 --- a/src/m2d/actions/send-event.js +++ b/src/m2d/actions/send-event.js @@ -62,7 +62,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) { diff --git a/src/m2d/actions/setup-emojis.js b/src/m2d/actions/setup-emojis.js new file mode 100644 index 0000000..ba2c045 --- /dev/null +++ b/src/m2d/actions/setup-emojis.js @@ -0,0 +1,26 @@ +// @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/event-to-message.js b/src/m2d/converters/event-to-message.js index e459f9f..3cf08cf 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -19,6 +19,8 @@ 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 = [ @@ -154,6 +156,27 @@ 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", { @@ -315,7 +338,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, name: string}[]} attachments + * @param {{id: string, filename: 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. */ @@ -329,9 +352,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 name = "emojis.png" - attachments.push({id: String(attachments.length), name}) - pendingFiles.push({name, buffer}) + const filename = "emojis.png" + attachments.push({id: String(attachments.length), filename}) + pendingFiles.push({name: filename, buffer}) return content } @@ -426,7 +449,7 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) { allowedMentionsParse: ["everyone"] } } - } else { + } 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 const results = await di.snow.guild.searchGuildMembers(guild.id, {query: writtenMentionMatch[1]}) if (results[0]) { assert(results[0].user) @@ -458,6 +481,23 @@ 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 @@ -486,6 +526,7 @@ 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 = [] @@ -493,7 +534,45 @@ async function eventToMessage(event, guild, di) { const ensureJoined = [] // Convert content depending on what the message is - if (event.type === "m.room.message" && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote")) { + // 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) { // 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 () => { @@ -507,7 +586,7 @@ async function eventToMessage(event, guild, di) { if (!messageIDsToEdit.length) return // Ok, it's an edit. - event.content = event.content["m.new_content"] + event = {...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 @@ -568,9 +647,7 @@ async function eventToMessage(event, guild, di) { return } - // @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")}>` + replyLine = await getL1L2ReplyLine() 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} ` @@ -780,40 +857,6 @@ 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 diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index d3871b3..3d1c918 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -3770,7 +3770,7 @@ test("event2message: text attachments work", async t => { username: "cadence [they]", content: "", avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", description: undefined, filename: "chiki-powerups.txt"}], + attachments: [{id: "0", filename: "chiki-powerups.txt"}], pendingFiles: [{name: "chiki-powerups.txt", mxc: "mxc://cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH"}] }] } @@ -3806,14 +3806,14 @@ test("event2message: image attachments work", async t => { username: "cadence [they]", content: "", avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", description: undefined, filename: "cool cat.png"}], + attachments: [{id: "0", filename: "cool cat.png"}], pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] }] } ) }) -test("event2message: image attachments can have a custom description", async t => { +test("event2message: image attachments can have a plaintext caption", async t => { t.deepEqual( await eventToMessage({ type: "m.room.message", @@ -3840,10 +3840,62 @@ test("event2message: image attachments can have a custom description", async t = messagesToEdit: [], messagesToSend: [{ username: "cadence [they]", - content: "", + content: "Cat emoji surrounded by pink hearts", avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", description: "Cat emoji surrounded by pink hearts", filename: "cool cat.png"}], - pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] + attachments: [{id: "0", filename: "cool cat.png"}], + pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}], + allowed_mentions: { + parse: ["users", "roles"] + } + }] + } + ) +}) + +test("event2message: image attachments can have a formatted caption", async t => { + t.deepEqual( + await eventToMessage({ + content: { + body: "this event has `formatting`", + filename: "5740.jpg", + format: "org.matrix.custom.html", + formatted_body: "this event has formatting
", + info: { + h: 1340, + mimetype: "image/jpeg", + size: 226689, + thumbnail_info: { + h: 670, + mimetype: "image/jpeg", + size: 80157, + w: 540 + }, + thumbnail_url: "mxc://thomcat.rocks/XhLsOCDBYyearsLQgUUrbAvw", + w: 1080, + "xyz.amorgan.blurhash": "KHJQG*55ic-.}?0M58J.9v" + }, + msgtype: "m.image", + url: "mxc://thomcat.rocks/RTHsXmcMPXmuHqVNsnbKtRbh" + }, + origin_server_ts: 1740607766895, + sender: "@cadence:cadence.moe", + type: "m.room.message", + event_id: "$NqNqVgukiQm1nynm9vIr9FIq31hZpQ3udOd7cBIW46U", + room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe" + }), + { + ensureJoined: [], + messagesToDelete: [], + messagesToEdit: [], + messagesToSend: [{ + username: "cadence [they]", + content: "this event has `formatting`", + avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", + attachments: [{id: "0", filename: "5740.jpg"}], + pendingFiles: [{name: "5740.jpg", mxc: "mxc://thomcat.rocks/RTHsXmcMPXmuHqVNsnbKtRbh"}], + allowed_mentions: { + parse: ["users", "roles"] + } }] } ) @@ -3892,7 +3944,7 @@ test("event2message: encrypted image attachments work", async t => { username: "cadence [they]", content: "", avatar_url: "https://bridge.example.org/download/matrix/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", description: undefined, filename: "image.png"}], + attachments: [{id: "0", filename: "image.png"}], pendingFiles: [{ name: "image.png", mxc: "mxc://heyquark.com/LOGkUTlVFrqfiExlGZNgCJJX", @@ -3904,6 +3956,91 @@ 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({ @@ -4485,6 +4622,42 @@ 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", diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 5f8cb2b..ce3638c 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -28,51 +28,126 @@ 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(/.*?
Col 1 Col 2 Col 3 Apple Banana Cherry Aardvark Bee Crocodile Argon Boron Carbon 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) { - console.error(`Exception while processing a ${type} Matrix event:`) - console.dir(event, {depth: null}) - - if (Date.now() - lastReportedEvent < 5000) return - lastReportedEvent = Date.now() - - const cloudflareErrorTitle = e.toString().match(/.*?Original payload
${util.inspect(payload, false, 4, false)}discord\.com \| ([^<]*)<\/title>/s)?.[1] - if (cloudflareErrorTitle) { - return api.sendEvent(event.room_id, "m.room.message", { - msgtype: "m.text", - body: `\u26a0 Matrix event not delivered to Discord. Cloudflare error: ${cloudflareErrorTitle}.`, - format: "org.matrix.custom.html", - formatted_body: `\u26a0 Matrix event not delivered to Discord
Cloudflare error: ${cloudflareErrorTitle}`, - "moe.cadence.ooye.error": { - source: "matrix", - payload: event - } - }) - } - - let stackLines = e.stack.split("\n") - api.sendEvent(event.room_id, "m.room.message", { - msgtype: "m.text", - body: "\u26a0 Matrix event not delivered to Discord. See formatted content for full details.", - format: "org.matrix.custom.html", - formatted_body: "\u26a0 Matrix event not delivered to Discord" - + `
Event type: ${type}` - + `
${e.toString()}` - + `` - + `Error trace
` - + `${stackLines.join("\n")}`, - "moe.cadence.ooye.error": { - source: "matrix", - payload: event - }, - "m.mentions": { - user_ids: ["@cadence:cadence.moe"] - } - }) + await sendError(event.room_id, "Matrix", type, e, event) } } } @@ -104,7 +179,7 @@ async function onRetryReactionAdd(reactionEvent) { } // Redact the error to stop people from executing multiple retries - api.redactEvent(roomID, event.event_id) + await api.redactEvent(roomID, event.event_id) } sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", @@ -114,6 +189,7 @@ 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) @@ -263,7 +339,13 @@ async event => { if (event.content.membership === "leave" || event.content.membership === "ban") { // Member is gone - return db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) + 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) + } } const exists = select("channel_room", "room_id", {room_id: event.room_id}) ?? select("guild_space", "space_id", {space_id: event.room_id}) @@ -297,3 +379,6 @@ 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 new file mode 100644 index 0000000..de754da --- /dev/null +++ b/src/m2d/event-dispatcher.test.js @@ -0,0 +1,23 @@ +// @ts-check + +const {test} = require("supertape") +const {stringifyErrorStack} = require("./event-dispatcher") + +test("stringify error stack: works", t => { + function a() { + const e = new Error("message", {cause: new Error("inner")}) + // @ts-ignore + e.prop = 2.1 + throw e + } + try { + a() + t.fail("shouldn't get here") + } catch (e) { + const str = stringifyErrorStack(e) + t.match(str, /^Error: message$/m) + t.match(str, /^ at a \(.*event-dispatcher\.test\.js/m) + t.match(str, /^ \[cause\]: Error: inner$/m) + t.match(str, /^ \[prop\]: 2.1$/m) + } +}) diff --git a/src/matrix/api.js b/src/matrix/api.js index 170802d..41af63f 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -181,6 +181,23 @@ async function getFullHierarchy(roomID) { return rooms } +/** + * Like `getFullHierarchy` but reveals a page at a time through an async iterator. + * @param {string} roomID + */ +async function* generateFullHierarchy(roomID) { + /** @type {string | undefined} */ + let nextBatch = undefined + do { + /** @type {Ty.HierarchyPaginationOriginal payload
` - + `${util.inspect(event, false, 4, false)}} */ + 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 @@ -291,21 +308,33 @@ async function profileSetAvatarUrl(mxid, avatar_url) { * Set a user's power level within a room. * @param {string} roomID * @param {string} mxid - * @param {number} power + * @param {number} newPower */ -async function setUserPower(roomID, mxid, power) { +async function setUserPower(roomID, mxid, newPower) { assert(roomID[0] === "!") assert(mxid[0] === "@") // Yes there's no shortcut https://github.com/matrix-org/matrix-appservice-bridge/blob/2334b0bae28a285a767fe7244dad59f5a5963037/src/components/intent.ts#L352 - const powerLevels = await getStateEvent(roomID, "m.room.power_levels", "") - powerLevels.users = powerLevels.users || {} - if (power != null) { - powerLevels.users[mxid] = power + 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] } else { - delete powerLevels.users[mxid] + power.users[mxid] = newPower } - await sendState(roomID, "m.room.power_levels", "", powerLevels) - return powerLevels + + await sendState(roomID, "m.room.power_levels", "", power, eventSender) + return power } /** @@ -419,6 +448,14 @@ 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 @@ -434,6 +471,7 @@ 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 @@ -452,3 +490,4 @@ 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 6eb75e0..2070a56 100644 --- a/src/matrix/file.js +++ b/src/matrix/file.js @@ -1,6 +1,9 @@ // @ts-check const passthrough = require("../passthrough") +const {reg, writeRegistration} = require("./read-registration.js") +const Ty = require("../types") + const {sync, db, select} = passthrough /** @type {import("./mreq")} */ const mreq = sync.require("./mreq") @@ -44,11 +47,8 @@ async function uploadDiscordFileToMxc(path) { return existingFromDb } - // Download from Discord - const promise = fetch(url, {}).then(async res => { - // Upload to Matrix - const root = await module.exports._actuallyUploadDiscordFileToMxc(urlNoExpiry, res) - + // Download from Discord and upload to Matrix + const promise = module.exports._actuallyUploadDiscordFileToMxc(url).then(root => { // Store relationship in database db.prepare("INSERT INTO file (discord_url, mxc_url) VALUES (?, ?)").run(urlNoExpiry, root.content_uri) inflight.delete(urlNoExpiry) @@ -62,17 +62,31 @@ async function uploadDiscordFileToMxc(path) { /** * @param {string} url - * @param {Response} res + * @returns {Promise } */ -async function _actuallyUploadDiscordFileToMxc(url, res) { - const body = res.body - /** @type {import("../types").R.FileUploaded} */ - const root = await mreq.mreq("POST", "/media/v3/upload", body, { - headers: { - "Content-Type": res.headers.get("content-type") +async function _actuallyUploadDiscordFileToMxc(url) { + const res = await fetch(url, {}) + try { + /** @type {Ty.R.FileUploaded} */ + const root = await mreq.mreq("POST", "/media/v3/upload", res.body, { + headers: { + "Content-Type": res.headers.get("content-type") + } + }) + return root + } catch (e) { + if (e instanceof mreq.MatrixServerError && e.data.error?.includes("Content-Length") && !reg.ooye.content_length_workaround) { + reg.ooye.content_length_workaround = true + const root = await _actuallyUploadDiscordFileToMxc(url) + console.error("OOYE cannot stream uploads to Synapse. The `content_length_workaround` option" + + "\nhas been activated in registration.yaml, which works around the problem, but" + + "\nhalves the speed of bridging d->m files. A better way to resolve this problem" + + "\nis to run an nginx reverse proxy to Synapse and re-run OOYE setup.") + writeRegistration(reg) + return root } - }) - return root + throw e + } } function guildIcon(guild) { @@ -84,8 +98,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) { @@ -95,18 +109,17 @@ function emoji(emojiID, animated) { } const stickerFormat = new Map([ - [1, {label: "PNG", ext: "png", mime: "image/png"}], - [2, {label: "APNG", ext: "png", mime: "image/apng"}], - [3, {label: "LOTTIE", ext: "json", mime: "lottie"}], - [4, {label: "GIF", ext: "gif", mime: "image/gif"}] + [1, {label: "PNG", ext: "png", mime: "image/png", endpoint: "/stickers/"}], + [2, {label: "APNG", ext: "png", mime: "image/apng", endpoint: "/stickers/"}], + [3, {label: "LOTTIE", ext: "json", mime: "lottie", endpoint: "/stickers/"}], + [4, {label: "GIF", ext: "gif", mime: "image/gif", endpoint: "https://media.discordapp.net/stickers/"}] ]) /** @param {{id: string, format_type: number}} sticker */ function sticker(sticker) { const format = stickerFormat.get(sticker.format_type) if (!format) throw new Error(`No such format ${sticker.format_type} for sticker ${JSON.stringify(sticker)}`) - const ext = format.ext - return `/stickers/${sticker.id}.${ext}` + return `${format.endpoint}${sticker.id}.${format.ext}` } module.exports.DISCORD_IMAGES_BASE = DISCORD_IMAGES_BASE diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 7a35e12..93bc312 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}:`) - const emoji = await discord.snow.guildAssets.createEmoji(guildID, {name: e.name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")}) + await discord.snow.assets.createGuildEmoji(guildID, {name: e.name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")}) } api.sendEvent(event.room_id, "m.room.message", { ...ctx, diff --git a/src/matrix/mreq.js b/src/matrix/mreq.js index 037ba7b..9179825 100644 --- a/src/matrix/mreq.js +++ b/src/matrix/mreq.js @@ -1,12 +1,11 @@ // @ts-check -const mixin = require("@cloudrac3r/mixin-deep") const stream = require("stream") const streamWeb = require("stream/web") -const getStream = require("get-stream") - -const {reg, writeRegistration} = require("./read-registration.js") +const {buffer} = require("stream/consumers") +const mixin = require("@cloudrac3r/mixin-deep") +const {reg} = require("./read-registration.js") const baseUrl = `${reg.ooye.server_origin}/_matrix` class MatrixServerError extends Error { @@ -19,20 +18,33 @@ 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} [body] + * @param {string | object | streamWeb.ReadableStream | stream.Readable} [bodyIn] * @param {any} [extra] */ -async function mreq(method, url, body, extra = {}) { - if (body == undefined || Object.is(body.constructor, Object)) { - body = JSON.stringify(body) - } else if (body instanceof stream.Readable && reg.ooye.content_length_workaround) { - body = await getStream.buffer(body) - } else if (body instanceof streamWeb.ReadableStream && reg.ooye.content_length_workaround) { - body = await stream.consumers.buffer(stream.Readable.fromWeb(body)) - } +async function mreq(method, url, bodyIn, extra = {}) { + const body = await _convertBody(bodyIn) /** @type {RequestInit} */ const opts = mixin({ @@ -43,21 +55,11 @@ async function mreq(method, url, body, extra = {}) { }, ...(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) { - 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}) } @@ -86,3 +88,4 @@ 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 new file mode 100644 index 0000000..7ac343e --- /dev/null +++ b/src/matrix/mreq.test.js @@ -0,0 +1,47 @@ +// @ts-check + +const assert = require("assert") +const stream = require("stream") +const streamWeb = require("stream/web") +const {buffer} = require("stream/consumers") +const {test} = require("supertape") +const {_convertBody} = require("./mreq") +const {reg} = require("./read-registration") + +async function *generator() { + yield "a" + yield "b" +} + +reg.ooye.content_length_workaround = false + +test("convert body: converts object to string", async t => { + t.equal(await _convertBody({a: "1"}), `{"a":"1"}`) +}) + +test("convert body: leaves undefined as undefined", async t => { + t.equal(await _convertBody(undefined), undefined) +}) + +test("convert body: leaves web readable as web readable", async t => { + const webReadable = stream.Readable.toWeb(stream.Readable.from(generator())) + t.equal(await _convertBody(webReadable), webReadable) +}) + +test("convert body: converts node readable to web readable (for native fetch upload)", async t => { + const readable = stream.Readable.from(generator()) + const webReadable = await _convertBody(readable) + assert(webReadable instanceof streamWeb.ReadableStream) + t.deepEqual(await buffer(webReadable), Buffer.from("ab")) +}) + +test("convert body: converts node readable to buffer", async t => { + reg.ooye.content_length_workaround = true + const readable = stream.Readable.from(generator()) + t.deepEqual(await _convertBody(readable), Buffer.from("ab")) +}) + +test("convert body: converts web readable to buffer", async t => { + const webReadable = stream.Readable.toWeb(stream.Readable.from(generator())) + t.deepEqual(await _convertBody(webReadable), Buffer.from("ab")) +}) diff --git a/src/types.d.ts b/src/types.d.ts index 62adf27..37da633 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -29,7 +29,8 @@ export type AppServiceRegistrationConfig = { include_user_id_in_mxid: boolean invite: string[] discord_origin?: string - discord_cdn_origin?: string + discord_cdn_origin?: string, + web_password: string } old_bridge?: { as_token: string @@ -166,6 +167,8 @@ 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 @@ -183,6 +186,8 @@ 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 diff --git a/src/web/auth.js b/src/web/auth.js index a4f9384..c14dcd8 100644 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -23,10 +23,10 @@ async function getManagedGuilds(event) { /** * @param {h3.H3Event} event - * @returns {ReturnType >} + * @returns {ReturnType >} */ function useSession(event) { - return h3.useSession(event, {password: reg.as_token}) + return h3.useSession(event, {password: reg.as_token, maxAge: 365 * 24 * 60 * 60}) } module.exports.getManagedGuilds = getManagedGuilds diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index c522d80..92ffa1b 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -54,13 +54,13 @@ block body .s-page-title.mb24 h1.s-page-title--header= guild.name - .d-flex.g16 + .d-flex.g16(class="sm:fw-wrap") .fl-grow1 h2.fs-headline1 Invite a Matrix user - form.d-grid.g-af-column.gy4.gx8.jc-start(method="post" action="/api/invite" hx-post="/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=rel("/api/invite") hx-post=rel("/api/invite") hx-trigger="submit" hx-swap="none" hx-on::after-request="if (event.detail.successful) this.reset()" hx-disabled-elt="input, button" hx-indicator="#invite-button") label.s-label(for="mxid") Matrix ID - input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\-]+\.[a-z0-9.:\-]+)") + input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org" pattern="@([^:]+):([a-z0-9:\\-]+\\.[a-z0-9.:\\-]+)") label.s-label(for="permissions") Permissions .s-select select#permissions(name="permissions") @@ -71,70 +71,16 @@ block body .grid--row-start2 button.s-btn.s-btn__filled#invite-button Invite div - .s-card.d-flex.ai-center.jc-center(style="min-width: 130px; min-height: 130px;") - button.s-btn.s-btn__filled(hx-get=`/qr?guild_id=${guild_id}` hx-indicator="closest button" hx-swap="outerHTML" hx-disabled-elt="this") Show QR + .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 if space_id - h2.mt48.fs-headline1 Matrix setup - - h3.mt32.fs-category Linked channels - - .s-card.bs-sm.p0 - form.s-table-container(method="post" action="/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="/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.mt32.fs-category Auto-create - .s-card - form.d-flex.ai-center.g8 - label.s-label.fl-grow1(for="autocreate") - | Create new Matrix rooms automatically - p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in. - - let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value autocomplete="off") - #autocreate-loading - - if space_id - h3.mt32.fs-category URL preview - .s-card - form.d-flex.ai-center.g8 - label.s-label.fl-grow1(for="url-preview") - | Show Discord's URL previews on Matrix - p.s-description Shows info about links posted to chat. Discord's previews are generally better quality than Synapse's, especially for social media and videos. - - let value = !!select("guild_space", "url_preview", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch.order-last#autocreate(name="url_preview" type="checkbox" hx-post="/api/url-preview" hx-indicator="#url-preview-loading" hx-disabled-elt="this" checked=value autocomplete="off") - #url-preview-loading - - h3.mt32.fs-category Presence - .s-card - form.d-flex.ai-center.g8 - label.s-label.fl-grow1(for="presence") - | Show online statuses on Matrix - p.s-description This might cause lag on really big Discord servers. - - value = !!select("guild_space", "presence", {guild_id}).pluck().get() - input(type="hidden" name="guild_id" value=guild_id) - input.s-toggle-switch.order-last#autocreate(name="presence" type="checkbox" hx-post="/api/presence" hx-indicator="#presence-loading" hx-disabled-elt="this" checked=value autocomplete="off") - #presence-loading - h3.mt32.fs-category Privacy level + span#privacy-level-loading .s-card - form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input") + 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) - .d-flex.ai-center.mb4 - label.s-label.fl-grow1 - | How people can join on Matrix - span#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="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") @@ -158,8 +104,58 @@ block body p.s-description.m0 Shareable invite links, like Discord p.s-description.m0 Publicly listed in directory, like Discord server discovery + h2.mt48.fs-headline1 Features + .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") + 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="/api/link" hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button") + 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) diff --git a/src/web/pug/guild_access_denied.pug b/src/web/pug/guild_access_denied.pug index 43cf361..42fea7b 100644 --- a/src/web/pug/guild_access_denied.pug +++ b/src/web/pug/guild_access_denied.pug @@ -6,7 +6,7 @@ block body != 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__blurple.s-btn__filled(href=rel("/oauth")) + 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")) diff --git a/src/web/pug/guild_not_linked.pug b/src/web/pug/guild_not_linked.pug index 771eca0..59de2fb 100644 --- a/src/web/pug/guild_not_linked.pug +++ b/src/web/pug/guild_not_linked.pug @@ -25,13 +25,13 @@ block body h3.mt32.fs-category Choose a space - form.s-card.bs-sm.p0.s-table-container.bar-md(method="post" action="/api/link-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="/api/link-space" hx-trigger="click" hx-disabled-elt="this") Link with this 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 @@ -44,7 +44,7 @@ block body h3.mt48.fs-category Auto-create .s-card - form.d-flex.ai-center.g8(method="post" action="/api/autocreate" hx-post="/api/autocreate" hx-indicator="#easy-mode-button") + 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 diff --git a/src/web/pug/home.pug b/src/web/pug/home.pug index a906423..d562250 100644 --- a/src/web/pug/home.pug +++ b/src/web/pug/home.pug @@ -1,24 +1,56 @@ extends includes/template.pug block body - .s-page-title.mb24 - h1.s-page-title--header Bridge a Discord server + - let locked = reg.ooye.web_password && reg.ooye.web_password !== session.data.password - .d-grid.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` + 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.] diff --git a/src/web/pug/includes/hash.svg b/src/web/pug/includes/hash.svg index 0f6fdd7..461f2dc 100644 --- a/src/web/pug/includes/hash.svg +++ b/src/web/pug/includes/hash.svg @@ -1,46 +1 @@ - - + diff --git a/src/web/pug/includes/template.pug b/src/web/pug/includes/template.pug index ff8c224..d9f1c30 100644 --- a/src/web/pug/includes/template.pug +++ b/src/web/pug/includes/template.pug @@ -61,6 +61,9 @@ html(lang="en") 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%; @@ -76,8 +79,6 @@ html(lang="en") .s-btn__dropdown:has(+ :popover-open) { background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important; } - +define-theme("blurple", "236", "84%", "64%") - +define-themed-button("blurple", "blurple") +define-themed-button("matrix", "black") body.themed.theme-system header.s-topbar @@ -91,11 +92,13 @@ html(lang="en") 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 with Matrix` + = ` Log in` + span(class="sm:d-none")= ` with Matrix` if !session.data.userID - a.s-btn.s-btn__icon.s-btn__blurple.s-btn__outlined.as-center(href=rel("/oauth")) + a.s-btn.s-btn__icon.s-btn__featured.s-btn__outlined.as-center(href=rel("/oauth")) != icons.Icons.IconDiscord - = ` Log in with Discord` + = ` 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)) diff --git a/src/web/pug/invite.pug b/src/web/pug/invite.pug index 8cb977b..81a428d 100644 --- a/src/web/pug/invite.pug +++ b/src/web/pug/invite.pug @@ -17,7 +17,7 @@ block body .fl-grow1 h2.fs-headline1 Invite a Matrix user - form.d-flex.gy16.fd-column(method="post" action="/api/invite" hx-post="/api/invite" hx-indicator="#invite-button" hx-select="#ok" hx-target="#form-container") + form.d-flex.gy16.fd-column(method="post" action=rel("/api/invite") hx-post=rel("/api/invite") hx-indicator="#invite-button" hx-select="#ok" hx-target="#form-container") .d-flex.gy4.fd-column label.s-label(for="mxid") Matrix ID input.fl-grow1.s-input.wmx3#mxid(name="mxid" required placeholder="@user:example.org") diff --git a/src/web/pug/log-in-with-matrix.pug b/src/web/pug/log-in-with-matrix.pug index 9853eaf..3bb72e2 100644 --- a/src/web/pug/log-in-with-matrix.pug +++ b/src/web/pug/log-in-with-matrix.pug @@ -6,11 +6,11 @@ block body .d-flex.g16#form-container .fl-grow1 - form.d-flex.gy16.fd-column(method="post" action="/api/log-in-with-matrix" hx-post="/api/log-in-with-matrix" hx-indicator="#log-in-button" hx-select="#ok" hx-target="#form-container") + 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.:\-]+)") + 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/routes/info.js b/src/web/routes/info.js new file mode 100644 index 0000000..0ccdeca --- /dev/null +++ b/src/web/routes/info.js @@ -0,0 +1,65 @@ +// @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 new file mode 100644 index 0000000..28dac3b --- /dev/null +++ b/src/web/routes/info.test.js @@ -0,0 +1,219 @@ +// @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 080ffc5..c5f404e 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -89,12 +89,12 @@ as.router.post("/api/link-space", defineEventHandler(async event => { try { powerLevelsStateContent = await api.getStateEvent(spaceID, "m.room.power_levels", "") } catch (e) {} - const selfPowerLevel = powerLevelsStateContent?.users?.[me] || powerLevelsStateContent?.users_default || 0 - if (selfPowerLevel < (powerLevelsStateContent?.state_default || 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix space"}) + const 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}.`}) + 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(() => { @@ -134,12 +134,14 @@ as.router.post("/api/link", defineEventHandler(async event => { if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} or room ID ${parsedBody.matrix} are already bridged and cannot be reused`}) // Check room is part of the guild's space - /** @type {Ty.Event.M_Space_Child?} */ - let spaceChildEvent = null - try { - spaceChildEvent = await api.getStateEvent(spaceID, "m.space.child", parsedBody.matrix) - } catch (e) {} - if (!Array.isArray(spaceChildEvent?.via)) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged 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 try { @@ -155,8 +157,8 @@ as.router.post("/api/link", defineEventHandler(async event => { try { powerLevelsStateContent = await api.getStateEvent(parsedBody.matrix, "m.room.power_levels", "") } catch (e) {} - const selfPowerLevel = powerLevelsStateContent?.users?.[me] || powerLevelsStateContent?.users_default || 0 - if (selfPowerLevel < (powerLevelsStateContent?.state_default || 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"}) + const 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) diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index 3c503cf..0d8d366 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -233,13 +233,7 @@ test("web link space: successfully adds entry to database and loads page", async mxid: "@cadence:cadence.moe" }, api: { - async getStateEvent(roomID, type, key) { - return {} - }, - async getMembers(roomID, membership) { - return {chunk: []} - }, - async getFullHierarchy(roomID) { + async getFullHierarchy(spaceID) { return [] } } @@ -344,7 +338,7 @@ test("web link room: checks the autocreate setting if the space doesn't exist ye t.equal(called, 1) }) -test("web link room: check that room is part of space (event missing)", async t => { +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: { @@ -356,37 +350,9 @@ test("web link room: check that room is part of space (event missing)", async t guild_id: "665289423482519565" }, api: { - async getStateEvent(roomID, type, key) { + async *generateFullHierarchy(spaceID) { called++ - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(type, "m.space.child") - t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - throw new MatrixServerError({errcode: "M_NOT_FOUND", error: "what if I told you there was no such thing as a space"}) - } - } - })) - t.equal(error.data, "Matrix room needs to be part of the bridged space") - t.equal(called, 1) -}) - -test("web link room: check that room is part of space (event empty)", 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 getStateEvent(roomID, type, key) { - called++ - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(type, "m.space.child") - t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - return {} + t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") } } })) @@ -410,12 +376,16 @@ test("web link room: check that bridge can join room", async t => { called++ throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) }, - async getStateEvent(roomID, type, key) { + async *generateFullHierarchy(spaceID) { called++ - t.equal(type, "m.space.child") - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - return {via: ["cadence.moe"]} + 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 */ } } })) @@ -439,17 +409,23 @@ test("web link room: check that bridge has PL 100 in target room (event missing) 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++ - if (type === "m.space.child") { - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - return {via: ["cadence.moe"]} - } else if (type === "m.room.power_levels") { - t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - 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(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"}) } } })) @@ -473,17 +449,23 @@ test("web link room: check that bridge has PL 100 in target room (users default) 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++ - if (type === "m.space.child") { - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - return {via: ["cadence.moe"]} - } else if (type === "m.room.power_levels") { - t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - t.equal(key, "") - return {users_default: 50} - } + t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users_default: 50} } } })) @@ -507,17 +489,23 @@ test("web link room: successfully calls createRoom", async t => { 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.space.child") { - called++ - t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") - t.equal(key, "!NDbIqNpJyPvfKRnNcr:cadence.moe") - return {via: ["cadence.moe"]} } else if (type === "m.room.name") { called++ t.equal(roomID, "!NDbIqNpJyPvfKRnNcr:cadence.moe") diff --git a/src/web/routes/log-in-with-matrix.js b/src/web/routes/log-in-with-matrix.js index 89d36c2..574c312 100644 --- a/src/web/routes/log-in-with-matrix.js +++ b/src/web/routes/log-in-with-matrix.js @@ -5,7 +5,7 @@ const {randomUUID} = require("crypto") const {defineEventHandler, getValidatedQuery, sendRedirect, readValidatedBody, createError, getRequestHeader, H3Event} = require("h3") const {LRUCache} = require("lru-cache") -const {as} = require("../../passthrough") +const {as, db, select} = require("../../passthrough") const {reg} = require("../../matrix/read-registration") const {sync} = require("../../passthrough") @@ -53,7 +53,7 @@ as.router.get("/log-in-with-matrix", defineEventHandler(async event => { } const userAgent = getRequestHeader(event, "User-Agent") - if (userAgent?.match(/bot/)) throw createError({status: 400, data: "Sorry URL previewer, you can't have this URL."}) + 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) @@ -71,7 +71,6 @@ as.router.get("/log-in-with-matrix", defineEventHandler(async event => { as.router.post("/api/log-in-with-matrix", defineEventHandler(async event => { const api = getAPI(event) const {mxid, next} = await readValidatedBody(event, schema.form.parse) - let roomID = null // Don't extend a duplicate invite for the same user for (const alreadyInvited of validToken.values()) { @@ -80,43 +79,32 @@ as.router.post("/api/log-in-with-matrix", defineEventHandler(async event => { } } - // See if we can reuse an existing room from account data - let directData = {} - try { - directData = await api.getAccountData("m.direct") - } catch (e) {} - const rooms = directData[mxid] || [] - for (const candidate of rooms) { + // 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 - let member try { - member = await api.getStateEvent(candidate, "m.room.member", mxid) + 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") { - // We can reinvite them back to the same room! - await api.inviteToRoom(candidate, mxid) - roomID = candidate - } else { - // Member is in this room - roomID = candidate + await api.inviteToRoom(roomID, mxid) } - if (roomID) break // no need to check other candidates } - // No candidates available, create a new room and invite - if (!roomID) { + // 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...) - ;(directData[mxid] ??= []).push(roomID) - await api.setAccountData("m.direct", directData) + db.prepare("REPLACE INTO direct (mxid, room_id) VALUES (?, ?)").run(mxid, roomID) } const token = randomUUID() - validToken.set(token, mxid) console.log(`web log in requested for ${mxid}`) const paramsObject = {token} @@ -129,5 +117,7 @@ as.router.post("/api/log-in-with-matrix", defineEventHandler(async event => { 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 index 1c37d9b..bc9c7e0 100644 --- a/src/web/routes/log-in-with-matrix.test.js +++ b/src/web/routes/log-in-with-matrix.test.js @@ -9,7 +9,7 @@ const {MatrixServerError} = require("../../matrix/mreq") 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"`) + t.has(html, `hx-post="api/log-in-with-matrix"`) }) // ***** second request ***** @@ -22,7 +22,7 @@ test("log in with matrix: checks if mxid format looks valid", async t => { mxid: "x@cadence:cadence.moe" } })) - t.equal(error.data.issues[0].validation, "regex") + t.match(error.data.fieldErrors.mxid, /must match pattern/) }) test("log in with matrix: checks if mxid domain format looks valid", async t => { @@ -31,10 +31,10 @@ test("log in with matrix: checks if mxid domain format looks valid", async t => mxid: "@cadence:cadence." } })) - t.equal(error.data.issues[0].validation, "regex") + t.match(error.data.fieldErrors.mxid, /must match pattern/) }) -test("log in with matrix: sends message when there is no m.direct data", async t => { +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", { @@ -42,20 +42,10 @@ test("log in with matrix: sends message when there is no m.direct data", async t mxid: "@cadence:cadence.moe" }, api: { - async getAccountData(type) { - called++ - t.equal(type, "m.direct") - throw new MatrixServerError({errcode: "M_NOT_FOUND"}) - }, async createRoom() { called++ return "!created:cadence.moe" }, - async setAccountData(type, content) { - called++ - t.equal(type, "m.direct") - t.deepEqual(content, {"@cadence:cadence.moe": ["!created:cadence.moe"]}) - }, async sendEvent(roomID, type, content) { called++ t.equal(roomID, "!created:cadence.moe") @@ -68,7 +58,7 @@ test("log in with matrix: sends message when there is no m.direct data", async t event }) t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/) - t.equal(called, 4) + t.equal(called, 2) }) test("log in with matrix: does not send another message when a log in is in progress", async t => { @@ -82,7 +72,7 @@ test("log in with matrix: does not send another message when a log in is in prog t.match(event.node.res.getHeader("location"), /We already sent you a link on Matrix/) }) -test("log in with matrix: reuses room from m.direct", async t => { +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", { @@ -90,11 +80,6 @@ test("log in with matrix: reuses room from m.direct", async t => { mxid: "@user1:example.org" }, api: { - async getAccountData(type) { - called++ - t.equal(type, "m.direct") - return {"@user1:example.org": ["!existing:cadence.moe"]} - }, async getStateEvent(roomID, type, key) { called++ t.equal(roomID, "!existing:cadence.moe") @@ -111,10 +96,10 @@ test("log in with matrix: reuses room from m.direct", async t => { event }) t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/) - t.equal(called, 3) + t.equal(called, 2) }) -test("log in with matrix: reuses room from m.direct, reinviting if user has left", async t => { +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", { @@ -122,11 +107,6 @@ test("log in with matrix: reuses room from m.direct, reinviting if user has left mxid: "@user2:example.org" }, api: { - async getAccountData(type) { - called++ - t.equal(type, "m.direct") - return {"@user2:example.org": ["!existing:cadence.moe"]} - }, async getStateEvent(roomID, type, key) { called++ t.equal(roomID, "!existing:cadence.moe") @@ -148,7 +128,7 @@ test("log in with matrix: reuses room from m.direct, reinviting if user has left event }) t.match(event.node.res.getHeader("location"), /Please check your inbox on Matrix/) - t.equal(called, 4) + t.equal(called, 3) }) // ***** third request ***** diff --git a/src/web/routes/oauth.js b/src/web/routes/oauth.js index ace7b72..80765d6 100644 --- a/src/web/routes/oauth.js +++ b/src/web/routes/oauth.js @@ -27,7 +27,7 @@ const schema = { token: z.object({ token_type: z.string(), access_token: z.string(), - expires_in: z.number({coerce: true}), + expires_in: z.coerce.number(), refresh_token: z.string(), scope: z.string() }) @@ -37,13 +37,15 @@ as.router.get("/oauth", defineEventHandler(async event => { const session = await auth.useSession(event) let scope = "guilds" - const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse) - if (parsedFirstQuery.data?.action === "add") { - scope = "bot+guilds" - await session.update({selfService: false}) - } else if (parsedFirstQuery.data?.action === "add-self-service") { - scope = "bot+guilds" - await session.update({selfService: true}) + if (!reg.ooye.web_password || reg.ooye.web_password === session.data.password) { + const parsedFirstQuery = await getValidatedQuery(event, schema.first.safeParse) + if (parsedFirstQuery.data?.action === "add") { + scope = "bot+guilds" + await session.update({selfService: false}) + } else if (parsedFirstQuery.data?.action === "add-self-service") { + scope = "bot+guilds" + await session.update({selfService: true}) + } } async function tryAgain() { diff --git a/src/web/routes/password.js b/src/web/routes/password.js new file mode 100644 index 0000000..e1dd299 --- /dev/null +++ b/src/web/routes/password.js @@ -0,0 +1,21 @@ +// @ts-check + +const {z} = require("zod") +const {defineEventHandler, readValidatedBody, sendRedirect} = require("h3") +const {as, sync} = require("../../passthrough") + +/** @type {import("../auth")} */ +const auth = sync.require("../auth") + +const schema = { + password: z.object({ + password: z.string() + }) +} + +as.router.post("/api/password", defineEventHandler(async event => { + const {password} = await readValidatedBody(event, schema.password.parse) + const session = await auth.useSession(event) + await session.update({password}) + return sendRedirect(event, "../") +})) diff --git a/src/web/server.js b/src/web/server.js index d4e7398..7c8ed3e 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -29,9 +29,11 @@ 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/oauth") sync.require("./routes/log-in-with-matrix") +sync.require("./routes/oauth") +sync.require("./routes/password") // Files diff --git a/src/web/server.test.js b/src/web/server.test.js index d24dfbc..6ed3535 100644 --- a/src/web/server.test.js +++ b/src/web/server.test.js @@ -1,14 +1,14 @@ // @ts-check const streamWeb = require("stream/web") -const {test} = require("supertape") +const {test} = require("../../test/web") const {router} = require("../../test/web") const assert = require("assert").strict require("./server") test("web server: can get home", async t => { - t.match(await router.test("get", "/", {}), /Add the bot to your Discord server./) + t.has(await router.test("get", "/", {}), /a bridge between the Discord and Matrix chat apps/) }) test("web server: can get htmx", async t => { diff --git a/test/data.js b/test/data.js index 1c44af5..a8ff8a8 100644 --- a/test/data.js +++ b/test/data.js @@ -37,19 +37,66 @@ module.exports = { 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: [], + 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", - flags: 0, 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: { @@ -75,7 +122,8 @@ module.exports = { "m.room.power_levels/": { events_default: 0, events: { - "m.reaction": 0 + "m.reaction": 0, + "m.room.redaction": 0 }, users: { "@test_auto_invite:example.org": 100 @@ -207,6 +255,25 @@ module.exports = { hoist: true, flags: 0, color: 16745267 + }, { + version: 1743122443142, + unicode_emoji: null, + tags: {}, + position: 3, + permissions: "0", + name: "Realdditors", + mentionable: true, + managed: false, + id: "1182745800661540927", + icon: null, + hoist: false, + flags: 0, + colors: { + tertiary_color: null, + secondary_color: null, + primary_color: 16729344 + }, + color: 16729344 } ], discovery_splash: null, @@ -329,7 +396,7 @@ module.exports = { unicode_emoji: null, tags: {}, position: 0, - permissions: "2221982107557441", + permissions: "968619318849", name: "@everyone", mentionable: false, managed: false, @@ -354,6 +421,21 @@ module.exports = { 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, @@ -394,6 +476,601 @@ module.exports = { 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: { @@ -411,6 +1088,19 @@ module.exports = { global_name: "Clyde", avatar_decoration_data: null, banner_color: null + }, + jerassicore: { + username: "ser_jurassicore", + public_flags: 0, + primary_guild: null, + id: "493801948345139202", + global_name: "Jurassicore", + display_name_styles: null, + discriminator: "0", + collectibles: null, + clan: null, + avatar_decoration_data: null, + avatar: "2a4fa0de3aaea30f457ed7bba64176aa" } }, member: { @@ -1643,6 +2333,95 @@ module.exports = { attachments: [], guild_id: "112760669178241024" }, + reply_to_matrix_user_mention: { + type: 19, + content: "kys", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2025-08-04T05:31:26.506000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1401799674192723998", + channel_id: "112760669178241024", + author: { + id: "114147806469554185", + username: "extremity", + avatar: "e0394d500407a8fa93774e1835b8b03a", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "Extremity", + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false, + message_reference: { + type: 0, + channel_id: "112760669178241024", + message_id: "1401760355339862066", + guild_id: "112760669178241024" + }, + referenced_message: { + type: 0, + content: "<@114147806469554185> you owe me $30", + mentions: [ + { + id: "114147806469554185", + username: "extremity", + avatar: "e0394d500407a8fa93774e1835b8b03a", + discriminator: "0", + public_flags: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: "Extremity", + avatar_decoration_data: null, + collectibles: null, + display_name_styles: null, + banner_color: null, + clan: null, + primary_guild: null + } + ], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2025-08-04T02:55:12.161000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1401760355339862066", + channel_id: "112760669178241024", + author: { + id: "1152700216189911081", + username: "okay 🤍 yay 🤍", + avatar: "90bc1d6912252d4fa9f92a2f5f6d347b", + discriminator: "0000", + public_flags: 0, + flags: 0, + bot: true, + global_name: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false, + application_id: "684280192553844747", + webhook_id: "1152700216189911081" + } + }, reply_with_video: { id: "1197621094983676007", type: 19, @@ -3025,6 +3804,7 @@ module.exports = { }, webhook_id: "1109360903096369153" }, + reply_with_only_embed: { type: 19, tts: false, @@ -3776,6 +4556,64 @@ 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: { @@ -4856,5 +5694,250 @@ module.exports = { application_id: "1109360903096369153", guild_id: "497159726455455754" } + }, + invite: { + irl: { + type: 0, + code: 'placeholder', + inviter: { + id: '772659086046658620', + username: 'cadence.worm', + avatar: '466df0c98b1af1e1388f595b4c1ad1b9', + discriminator: '0', + public_flags: 0, + flags: 0, + banner: null, + accent_color: 4534897, + global_name: 'cadence', + avatar_decoration_data: null, + collectibles: null, + banner_color: '#453271', + clan: null, + primary_guild: null + }, + expires_at: '2025-06-15T08:39:43+00:00', + guild: { + id: '1338114140941586518', + name: 'self service', + splash: null, + banner: null, + description: null, + icon: null, + features: [], + verification_level: 0, + vanity_url_code: null, + nsfw_level: 0, + nsfw: false, + premium_subscription_count: 0, + premium_tier: 0 + }, + guild_id: '1338114140941586518', + channel: { id: '1338114141658939517', type: 0, name: 'general' }, + guild_scheduled_event: { + id: '1381190945646710824', + guild_id: '1338114140941586518', + name: 'forest exploration', + description: '', + channel_id: null, + creator_id: '772659086046658620', + image: null, + scheduled_start_time: '2025-06-08T10:00:00.161000+00:00', + scheduled_end_time: '2025-06-08T12:00:00.161000+00:00', + status: 1, + entity_type: 3, + entity_id: null, + recurrence_rule: null, + user_count: 1, + privacy_level: 2, + sku_ids: [], + user_rsvp: null, + guild_scheduled_event_exceptions: [], + entity_metadata: { location: 'the dark forest' } + }, + profile: { + id: '1338114140941586518', + name: 'self service', + icon_hash: null, + member_count: 2, + online_count: 1, + description: null, + banner_hash: null, + game_application_ids: [], + game_activity: {}, + tag: null, + badge: 0, + badge_color_primary: '#ff0000', + badge_color_secondary: '#800000', + badge_hash: null, + traits: [], + features: [], + visibility: 2, + custom_banner_hash: null, + premium_subscription_count: 0, + premium_tier: 0 + } + }, + vc: { + type: 0, + code: 'placeholder', + inviter: { + id: '1024720274928697384', + username: '1024720274928697384', + avatar: '040a0652f1c76af3b71bb2c58ee0057b', + discriminator: '0', + public_flags: 0, + flags: 0, + banner: null, + accent_color: 4259841, + global_name: 'Regalia, Goddess of OH GOD OH FU', + avatar_decoration_data: null, + collectibles: null, + banner_color: '#410001', + clan: null, + primary_guild: null + }, + expires_at: '2025-06-15T07:32:30+00:00', + guild: { + id: '1340545485542391879', + name: 'VRCooking', + splash: null, + banner: null, + description: null, + icon: '8e1948b83d79c11ccb32b9e54a5d85fd', + features: [ 'SOUNDBOARD', 'ACTIVITY_FEED_DISABLED_BY_USER' ], + verification_level: 0, + vanity_url_code: null, + nsfw_level: 0, + nsfw: false, + premium_subscription_count: 0, + premium_tier: 0 + }, + guild_id: '1340545485542391879', + channel: { id: '1368144987707019306', type: 2, name: 'Cooking' }, + guild_scheduled_event: { + id: '1381174024801095751', + guild_id: '1340545485542391879', + name: 'Cooking (Netrunners)', + description: 'Short circuited brain interfaces actually just means your brain is medium rare, yum.', + channel_id: '1368144987707019306', + creator_id: '1024720274928697384', + image: null, + scheduled_start_time: '2025-06-09T03:00:00+00:00', + scheduled_end_time: null, + status: 1, + entity_type: 2, + entity_id: null, + recurrence_rule: null, + user_count: 2, + privacy_level: 2, + sku_ids: [], + user_rsvp: null, + guild_scheduled_event_exceptions: [], + entity_metadata: {} + }, + profile: { + id: '1340545485542391879', + name: 'VRCooking', + icon_hash: '8e1948b83d79c11ccb32b9e54a5d85fd', + member_count: 18, + online_count: 13, + description: null, + banner_hash: null, + game_application_ids: [], + game_activity: {}, + tag: null, + badge: 0, + badge_color_primary: '#ff0000', + badge_color_secondary: '#800000', + badge_hash: null, + traits: [], + features: [], + visibility: 2, + custom_banner_hash: null, + premium_subscription_count: 0, + premium_tier: 0 + } + }, + known_vc: { + type: 0, + code: 'placeholder', + inviter: { + id: '1024720274928697384', + username: '1024720274928697384', + avatar: '040a0652f1c76af3b71bb2c58ee0057b', + discriminator: '0', + public_flags: 0, + flags: 0, + banner: null, + accent_color: 4259841, + global_name: 'Regalia, Goddess of OH GOD OH FU', + avatar_decoration_data: null, + collectibles: null, + banner_color: '#410001', + clan: null, + primary_guild: null + }, + expires_at: '2025-06-15T07:32:30+00:00', + guild: { + id: '112760669178241024', + name: 'Psychonauts 3', + splash: null, + banner: null, + description: null, + icon: '8e1948b83d79c11ccb32b9e54a5d85fd', + features: [ 'SOUNDBOARD', 'ACTIVITY_FEED_DISABLED_BY_USER' ], + verification_level: 0, + vanity_url_code: null, + nsfw_level: 0, + nsfw: false, + premium_subscription_count: 0, + premium_tier: 0 + }, + guild_id: '112760669178241024', + channel: { id: '1162005314908999790', type: 0, name: 'Hey.' }, + guild_scheduled_event: { + id: '1381174024801095751', + guild_id: '112760669178241024', + name: 'Cooking (Netrunners)', + description: 'Short circuited brain interfaces actually just means your brain is medium rare, yum.', + channel_id: '1162005314908999790', + creator_id: '1024720274928697384', + image: null, + scheduled_start_time: '2025-06-09T03:00:00+00:00', + scheduled_end_time: null, + status: 1, + entity_type: 2, + entity_id: null, + recurrence_rule: null, + user_count: 2, + privacy_level: 2, + sku_ids: [], + user_rsvp: null, + guild_scheduled_event_exceptions: [], + entity_metadata: {} + }, + profile: { + id: '112760669178241024', + name: 'Psychonauts 3', + icon_hash: '8e1948b83d79c11ccb32b9e54a5d85fd', + member_count: 18, + online_count: 13, + description: null, + banner_hash: null, + game_application_ids: [], + game_activity: {}, + tag: null, + badge: 0, + badge_color_primary: '#ff0000', + badge_color_secondary: '#800000', + badge_hash: null, + traits: [], + features: [], + visibility: 2, + custom_banner_hash: null, + premium_subscription_count: 0, + premium_tier: 0 + } + } } } diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index e3f0478..b31f2c3 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -70,7 +70,10 @@ INSERT INTO message_channel (message_id, channel_id) VALUES ('1278002262400176128', '1100319550446252084'), ('1278001833876525057', '1100319550446252084'), ('1191567971970191490', '176333891320283136'), -('1144874214311067708', '687028734322147344'); +('1144874214311067708', '687028734322147344'), +('1339000288144658482', '176333891320283136'), +('1381212840957972480', '112760669178241024'), +('1401760355339862066', '112760669178241024'); 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), @@ -110,7 +113,11 @@ 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); +('$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); INSERT INTO file (discord_url, mxc_url) VALUES ('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'), @@ -155,7 +162,8 @@ INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) V ('!TqlyQmifxGUggEmdBN:cadence.moe', '@Milan:tchncs.de', 'Milan', NULL, 0), ('!TqlyQmifxGUggEmdBN:cadence.moe', '@ampflower:matrix.org', 'Ampflower 🌺', 'mxc://cadence.moe/PRfhXYBTOalvgQYtmCLeUXko', 0), ('!TqlyQmifxGUggEmdBN:cadence.moe', '@aflower:syndicated.gay', 'Rose', 'mxc://syndicated.gay/ZkBUPXCiXTjdJvONpLJmcbKP', 0), -('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0); +('!TqlyQmifxGUggEmdBN:cadence.moe', '@cadence:cadence.moe', 'cadence [they]', NULL, 0), +('!iSyXgNxQcEuXoXpsSn:pussthecat.org', '@austin:tchncs.de', 'Austin Huang', 'mxc://tchncs.de/090a2b5e07eed2f71e84edad5207221e6c8f8b8e', 0); INSERT INTO reaction (hashed_event_id, message_id, encoded_emoji) VALUES (5162930312280790092, '1141501302736695317', '%F0%9F%90%88'); @@ -166,10 +174,9 @@ 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, guild_id) VALUES -('L1', '1144820033948762203', '529176156398682115'), -('L2', '1144820084079087647', '529176156398682115'), -('_', '_', '529176156398682115'); +INSERT INTO auto_emoji (name, emoji_id) VALUES +('L1', '1144820033948762203'), +('L2', '1144820084079087647'); INSERT INTO media_proxy (permitted_hash) VALUES (-429802515645771439), @@ -181,4 +188,8 @@ INSERT INTO invite (mxid, room_id, type, name, avatar, topic) VALUES ('@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 711244b..3695a84 100644 --- a/test/test.js +++ b/test/test.js @@ -29,6 +29,7 @@ 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], @@ -42,6 +43,7 @@ const discord = { 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], @@ -126,10 +128,19 @@ 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") @@ -145,6 +156,7 @@ 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") @@ -155,11 +167,4 @@ 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") - require("../src/web/routes/guild-settings.test") - require("../src/web/routes/link.test") - require("../src/web/routes/log-in-with-matrix.test") })() diff --git a/test/web.js b/test/web.js index 0595a96..09af95b 100644 --- a/test/web.js +++ b/test/web.js @@ -5,6 +5,10 @@ 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 @@ -39,7 +43,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) } } } @@ -49,7 +53,7 @@ class Router { * @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] */ - test(method, inputUrl, options = {}) { + async test(method, inputUrl, options = {}) { const url = new URL(inputUrl, "http://a") const key = `${method} ${options.route || url.pathname}` /* c8 ignore next */ @@ -67,36 +71,42 @@ class Router { req.headers["content-type"] = "application/json" } - return 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 || {} + 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 || {} + } } } - } - })) + })) + } catch (error) { + // Post-process error data + defaultAs.app.options.onError(error) + throw error + } } } const router = new Router() -passthrough.as = {router} +passthrough.as = {router, on() {}, options: defaultAs.app.options} module.exports.router = router module.exports.test = test