diff --git a/docs/developer-orientation.md b/docs/developer-orientation.md index 0f5e0c0..bff64c2 100644 --- a/docs/developer-orientation.md +++ b/docs/developer-orientation.md @@ -113,7 +113,6 @@ Total transitive production dependencies: 144 * (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer. * (1) chunk-text: It does what I want. * (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust. -* (0) denque: Much faster than using arrays as a queue. * (0) discord-api-types: Bitfields needed at runtime and types needed for development. * (0) domino: DOM implementation that's already pulled in by turndown. * (2) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively. diff --git a/package-lock.json b/package-lock.json index 04dbbe4..eb07b4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "out-of-your-element", - "version": "3.6.0", + "version": "3.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "out-of-your-element", - "version": "3.6.0", + "version": "3.5.1", "license": "AGPL-3.0-or-later", "dependencies": { "@chriscdn/promise-semaphore": "^3.0.1", @@ -18,14 +18,13 @@ "@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/stream-type": "^1.0.0", - "@cloudrac3r/turndown": "^7.1.5", + "@cloudrac3r/turndown": "^7.1.4", "@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks-icons": "^6.0.2", "ansi-colors": "^4.1.3", "better-sqlite3": "^12.2.0", "chunk-text": "^2.0.1", - "cloudstorm": "^0.19.0", - "denque": "^2.1.0", + "cloudstorm": "^0.17.1", "discord-api-types": "^0.38.38", "domino": "^2.1.6", "enquirer": "^2.4.1", @@ -38,7 +37,7 @@ "mime-types": "^2.1.35", "prettier-bytes": "^1.0.4", "sharp": "^0.34.5", - "snowtransfer": "^0.19.0", + "snowtransfer": "^0.17.5", "try-to-catch": "^4.0.5", "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", @@ -268,9 +267,9 @@ } }, "node_modules/@cloudrac3r/turndown": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.5.tgz", - "integrity": "sha512-pENoG62B4UM2ecsg57cuSRDa1OV/YFYTFGg1aoQsKUdHBBglhE9I7EwEMYpS96QoM/auyglfaxjgTeqhcSGCIA==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.4.tgz", + "integrity": "sha512-bQAwcvcSqBTdEHPMt+IAZWIoDh+2eRuy9TgD0FUdxVurbvj3CUHTxLfzlmsO0UTi+GHpgYqDSsVdV7kYTNq5Qg==", "license": "MIT", "dependencies": { "domino": "^2.1.6" @@ -1317,13 +1316,13 @@ } }, "node_modules/cloudstorm": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.19.0.tgz", - "integrity": "sha512-mjuP5o2nS/CgSljajh08KjN6jYMfNBBttSPHc1YUN74f7OkmhUhv8QE8JFDXomxCfdnLNJcqBQtjRLrs8nkfVQ==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.17.1.tgz", + "integrity": "sha512-LYUwzHagRYRd93XocOqi+HCHdzPYI9cW7Yf7pYqinxgG+Qka1OiqBKWTCcLiEuiqXaOV30kr8c6aZ/c1QcDP4Q==", "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.49", - "snowtransfer": "^0.19.0" + "discord-api-types": "^0.38.47", + "snowtransfer": "^0.17.5" }, "engines": { "node": ">=22.0.0" @@ -1458,15 +1457,6 @@ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "license": "MIT" }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -1483,9 +1473,9 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.49", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", - "integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==", + "version": "0.38.47", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", + "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" @@ -2589,12 +2579,12 @@ } }, "node_modules/snowtransfer": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.19.0.tgz", - "integrity": "sha512-Vebj4FCdpUsEKAzqC1p50ol5o09CLnrffvBCpTv0FNFefvXPXwogxR8kr4zJApIRt7oWQVZZVkxAq4XnGlICjQ==", + "version": "0.17.7", + "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.7.tgz", + "integrity": "sha512-scbOjYezo1Ycfk21atCEkeXIISTT7R7JTHCdiZ/7m7k4XbSb6o5q8Mu2fev5IqFpNyqIVjA0d/MZQ+eP/gtwfg==", "license": "MIT", "dependencies": { - "discord-api-types": "^0.38.49" + "discord-api-types": "^0.38.47" }, "engines": { "node": ">=22.0.0" diff --git a/package.json b/package.json index c086517..9dfd2a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "out-of-your-element", - "version": "3.6.0", + "version": "3.5.1", "description": "A bridge between Matrix and Discord", "main": "index.js", "repository": { @@ -27,14 +27,13 @@ "@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/stream-type": "^1.0.0", - "@cloudrac3r/turndown": "^7.1.5", + "@cloudrac3r/turndown": "^7.1.4", "@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks-icons": "^6.0.2", "ansi-colors": "^4.1.3", "better-sqlite3": "^12.2.0", "chunk-text": "^2.0.1", - "cloudstorm": "^0.19.0", - "denque": "^2.1.0", + "cloudstorm": "^0.17.1", "discord-api-types": "^0.38.38", "domino": "^2.1.6", "enquirer": "^2.4.1", @@ -47,7 +46,7 @@ "mime-types": "^2.1.35", "prettier-bytes": "^1.0.4", "sharp": "^0.34.5", - "snowtransfer": "^0.19.0", + "snowtransfer": "^0.17.5", "try-to-catch": "^4.0.5", "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", diff --git a/scripts/setup.js b/scripts/setup.js index 6ce4c9f..69b62a2 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -359,7 +359,7 @@ function defineEchoHandler() { console.log("โœ… Emojis are ready...") // set profile data on discord... - await discord.snow.user.editSelf({avatar: "data:image/png;base64," + avatarBuffer.toString("base64")}) + await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + avatarBuffer.toString("base64")}) console.log("โœ… Discord profile updated...") // set profile data on homeserver... diff --git a/src/d2m/actions/create-space.js b/src/d2m/actions/create-space.js index 403fdb2..7a751e2 100644 --- a/src/d2m/actions/create-space.js +++ b/src/d2m/actions/create-space.js @@ -8,8 +8,6 @@ const {reg} = require("../../matrix/read-registration") const passthrough = require("../../passthrough") const {discord, sync, db, select} = passthrough -/** @type {import("../../matrix/mreq")} */ -const mreq = sync.require("../../matrix/mreq") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") /** @type {import("../../matrix/file")} */ @@ -239,12 +237,8 @@ async function syncSpaceExpressions(data, checkBeforeSync) { try { existing = await api.getStateEvent(spaceID, "im.ponies.room_emotes", eventKey) } catch (e) { - if (e instanceof mreq.MatrixServerError && e.httpStatus < 400) { - // State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake. - existing = fn([], guild) - } else { - throw e - } + // State event not found. This space doesn't have any existing emojis. We create a dummy empty event for comparison's sake. + existing = fn([], guild) } if (isDeepStrictEqual(existing, content)) return } diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index 5b3b4f3..8550d43 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -60,7 +60,8 @@ async function sendMessage(message, channel, guild, row) { const detailedResultsMessage = await pollEnd.endPoll(message) if (detailedResultsMessage) { const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get() - const {channelID, threadID} = dUtils.swapThreadID(message.channel_id, threadParent) + const channelID = threadParent ? threadParent : message.channel_id + const threadID = threadParent ? message.channel_id : undefined sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID) } } diff --git a/src/d2m/actions/speedbump.js b/src/d2m/actions/speedbump.js index 42e3a35..218f046 100644 --- a/src/d2m/actions/speedbump.js +++ b/src/d2m/actions/speedbump.js @@ -1,5 +1,6 @@ // @ts-check +const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../../passthrough") const {discord, select, db} = passthrough @@ -69,18 +70,12 @@ async function doSpeedbump(messageID) { * Check whether to slow down a message, and do it. After it passes the speedbump, return whether it's okay or if it's been deleted. * @param {string} channelID * @param {string} messageID - * @param {string} [userID] if provided, only slow down the message when the user has used PK before * @returns whether it was deleted, and data about the channel's (not thread's) speedbump */ -async function maybeDoSpeedbump(channelID, messageID, userID) { - let row = select("channel_room", ["room_id", "thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get() - if (row?.thread_parent) row = select("channel_room", ["room_id", "thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread - if (!row?.speedbump_webhook_id) return {affected: false, row: null} // channel not affected, no speedbump - if (userID) { - if (row.speedbump_webhook_id === userID) return {affected: false, row} // shortcut - const userHasProxy = select("sim_proxy", "user_id", {proxy_owner_id: userID}).pluck().get() - if (!userHasProxy) return {affected: false, row} // user has not used PK before, no speedbump - } +async function maybeDoSpeedbump(channelID, messageID) { + let row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get() + if (row?.thread_parent) row = select("channel_room", ["thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread + if (!row?.speedbump_webhook_id) return {affected: false, row: null} // not affected, no speedbump const affected = await doSpeedbump(messageID) return {affected, row} // maybe affected, and there is a speedbump } diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index aeda572..7229d3d 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -265,9 +265,8 @@ function getFormattedInteraction(interaction, isThinkingInteraction) { * @param {any} newEvents merge into events * @param {any} events will be modified * @param {boolean} forceSameMsgtype whether m.text may only be combined with m.text, etc - * @param {boolean} [forceMerge] if true, must merge event, will error if it had to append */ -function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false) { +function mergeTextEvents(newEvents, events, forceSameMsgtype) { let prev = events.at(-1) for (const ne of newEvents) { const isAllText = prev?.body && prev?.formatted_body && ["m.text", "m.notice"].includes(ne.msgtype) && ["m.text", "m.notice"].includes(prev?.msgtype) @@ -279,8 +278,6 @@ function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false rep.addLine(ne.body, ne.formatted_body) prev.body = rep.body prev.formatted_body = rep.formattedBody - } else if (forceMerge) { - throw new Error("Unable to merge events") } else { events.push(ne) } @@ -557,7 +554,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Handling emojis that we don't know about. The emoji has to be present in the DB for it to be picked up in the emoji markdown converter. // So we scan the message ahead of time for all its emojis and ensure they are in the DB. - const emojiMatches = [...content.matchAll(/<(a?):([^:>]+):([0-9]+)>/g)] + const emojiMatches = [...content.matchAll(/<(a?):([^:>]{1,64}):([0-9]+)>/g)] await Promise.all(emojiMatches.map(match => { const id = match[3] const name = match[2] @@ -970,8 +967,7 @@ async function messageToEvent(message, guild, options = {}, di) { // May only be a section accessory or in an action row (up to 5) if (component.style === DiscordTypes.ButtonStyle.Link) { assert(component.label) // required for Discord to validate link buttons - const link = await transformContentMessageLinks(component.url) - stack.msb.add(`[${component.label} ${link}] `, tag`${component.label} `) + stack.msb.add(`[${component.label} ${component.url}] `, tag`${component.label} `) } } @@ -984,19 +980,7 @@ async function messageToEvent(message, guild, options = {}, di) { const {body, formatted_body} = stack.msb.get() if (body.trim().length) { - // Create new message if Components V2 (cannot have regular content) - if ((message.flags ?? 0) & DiscordTypes.MessageFlags.IsComponentsV2) { - await addTextEvent(body, formatted_body, "m.text") - } - // Add to existing message if legacy components https://docs.discord.com/developers/components/reference#legacy-message-component-behavior - else { - mergeTextEvents([{ - msgtype: "m.text", - body, - format: "org.matrix.custom.html", - formatted_body - }], events, false, true) - } + await addTextEvent(body, formatted_body, "m.text") } } diff --git a/src/d2m/converters/message-to-event.test.components.js b/src/d2m/converters/message-to-event.test.components.js index 1ef83c3..137b63b 100644 --- a/src/d2m/converters/message-to-event.test.components.js +++ b/src/d2m/converters/message-to-event.test.components.js @@ -1,7 +1,6 @@ const {test} = require("supertape") const {messageToEvent} = require("./message-to-event") const data = require("../../../test/data") -const {mockGetEffectivePower} = require("../../matrix/utils.test") test("message2event components: pk question mark output", async t => { const events = await messageToEvent(data.message_with_components.pk_question_mark_response, data.guild.general, {}) @@ -78,24 +77,3 @@ test("message2event components: pk question mark output", async t => { msgtype: "m.text", }]) }) - -test("message2event components: pk ping message legacy components", async t => { - const events = await messageToEvent(data.message_with_components.pk_ping_components_v1, data.guild.general, {}, { - api: { - async getJoinedMembers() { - return {joined: {}} - }, - getEffectivePower: mockGetEffectivePower() - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "โญ cadence used `/๐Ÿ”” Ping author`" - + "\nPsst, **Red** (@cadence.worm:), you have been pinged by @cadence.worm:." - + "\n[Jump https://matrix.to/#/!TqlyQmifxGUggEmdBN:cadence.moe/$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM?via=cadence.moe] ", - format: "org.matrix.custom.html", - formatted_body: "
โญ cadence used /๐Ÿ”” Ping author
Psst, Red (@cadence.worm), you have been pinged by @cadence.worm.
Jump ", - "m.mentions": {} - }]) -}) diff --git a/src/d2m/discord-client.js b/src/d2m/discord-client.js index b9b45e2..c2a0549 100644 --- a/src/d2m/discord-client.js +++ b/src/d2m/discord-client.js @@ -14,8 +14,6 @@ const {sync} = passthrough /** @type {import("./discord-packets")} */ const discordPackets = sync.require("./discord-packets") -const CONNECTION_DEBUG = false - class DiscordClient { /** * @param {string} discordToken @@ -26,7 +24,7 @@ class DiscordClient { const intents = [ "DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING", "GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS", - "MESSAGE_CONTENT", "GUILD_MEMBERS" + "MESSAGE_CONTENT" ] if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES") this.discordToken = discordToken @@ -61,17 +59,15 @@ class DiscordClient { }) } - if (CONNECTION_DEBUG) { - const addEventLogger = (eventName, logName) => { - this.cloud.on(eventName, (...args) => { - const d = new Date().toISOString().slice(0, 19) - console.error(`[${d} Client ${logName}]`, ...args) - }) - } - addEventLogger("error", "Error") - addEventLogger("disconnected", "Disconnected") - addEventLogger("ready", "Ready") + const addEventLogger = (eventName, logName) => { + this.cloud.on(eventName, (...args) => { + const d = new Date().toISOString().slice(0, 19) + console.error(`[${d} Client ${logName}]`, ...args) + }) } + addEventLogger("error", "Error") + addEventLogger("disconnected", "Disconnected") + addEventLogger("ready", "Ready") } } diff --git a/src/d2m/discord-packets.js b/src/d2m/discord-packets.js index b17ad4e..afea9ea 100644 --- a/src/d2m/discord-packets.js +++ b/src/d2m/discord-packets.js @@ -1,222 +1,173 @@ // @ts-check -const assert = require("assert") -const {scheduler} = require("timers/promises") +// Discord library internals type beat + +const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../passthrough") -const {sync} = passthrough +const {sync, db} = passthrough -/** @type {import("../matrix/homeserver-status")} */ -const homeserverStatus = sync.require("../matrix/homeserver-status") +const utils = { + /** + * @param {import("./discord-client")} client + * @param {import("cloudstorm").IGatewayMessage} message + * @param {string} listen "full", "half", "no" - whether to set up the event listeners for OOYE to operate + */ + async onPacket(client, message, listen) { + // requiring this later so that the client is already constructed by the time event-dispatcher is loaded + /** @type {typeof import("./event-dispatcher")} */ + const eventDispatcher = sync.require("./event-dispatcher") + /** @type {import("../discord/register-interactions")} */ + const interactions = sync.require("../discord/register-interactions") -let checkedHomeserver = false + // Client internals, keep track of the state we need + if (message.t === "READY") { + if (client.ready) return + client.ready = true + client.user = message.d.user + client.application = message.d.application + console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`) + interactions.registerInteractions() -/** - * @param {import("./discord-client")} client - * @param {import("cloudstorm").IGatewayMessage} message - * @param {string} listen "full", "half", "no" - whether to set up the event listeners for OOYE to operate - */ -async function onPacket(client, message, listen) { - // requiring this later so that the client is already constructed by the time event-dispatcher is loaded - /** @type {typeof import("./event-dispatcher")} */ - const eventDispatcher = sync.require("./event-dispatcher") - /** @type {import("../discord/register-interactions")} */ - const interactions = sync.require("../discord/register-interactions") + } else if (message.t === "GUILD_CREATE") { + message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web + client.guilds.set(message.d.id, message.d) + const arr = [] + client.guildChannelMap.set(message.d.id, arr) + for (const channel of message.d.channels || []) { + // @ts-ignore + channel.guild_id = message.d.id + arr.push(channel.id) + client.channels.set(channel.id, channel) + } + for (const thread of message.d.threads || []) { + // @ts-ignore + thread.guild_id = message.d.id + arr.push(thread.id) + client.channels.set(thread.id, thread) + } - // Client internals, keep track of the state we need - if (message.t === "READY") { - if (client.ready) return - client.ready = true - client.user = message.d.user - client.application = message.d.application - console.log(`Discord logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`) - interactions.registerInteractions() - - } else if (message.t === "GUILD_CREATE") { - message.d.members = message.d.members.filter(m => m.user.id === client.user.id) // only keep the bot's own member - it's needed to determine private channels on web - client.guilds.set(message.d.id, message.d) - const arr = [] - client.guildChannelMap.set(message.d.id, arr) - for (const channel of message.d.channels || []) { - // @ts-ignore - channel.guild_id = message.d.id - arr.push(channel.id) - client.channels.set(channel.id, channel) - } - for (const thread of message.d.threads || []) { - // @ts-ignore - thread.guild_id = message.d.id - arr.push(thread.id) - client.channels.set(thread.id, thread) - } - - if (listen === "full") { - try { - /* - Info about guilds is populated one guild at a time. - For m->d bridging to work, the guild needs to be populated, so we need to have GUILD_CREATE for the guild. - If we ping the homeserver, it will send us any pending events, so we need to wait for all GUILD_CREATES before we ping. - We must attempt a ping because we don't want to try sending missed d->m messages to an offline homeserver. - This delay can be removed if ONE of the following is done: - 1. m->d can queue incoming events until their guild exists in memory - 2. d->m missed messages can have their errors handled and added to queue, rather than pinging first - */ - let isMainCharacter = false - if (!checkedHomeserver) { - checkedHomeserver = true - isMainCharacter = true - console.log("Warming up guilds~") + if (listen === "full") { + try { + await eventDispatcher.checkMissedExpressions(message.d) + await eventDispatcher.checkMissedMessages(client, message.d) + await eventDispatcher.checkMissedPins(client, message.d) + await eventDispatcher.checkMissedLeaves(client, message.d) + } catch (e) { + console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:") + console.error(e) } - await scheduler.wait(5000) - if (isMainCharacter) { - checkedHomeserver = true - process.stdout.write("Connecting to homeserver... ") - await homeserverStatus.homeserverStatus.waitForOnline(true) - console.log("ok.\nReplaying past events. Welcome to Out Of Your Element.") + } + + } else if (message.t === "GUILD_UPDATE") { + const guild = client.guilds.get(message.d.id) + if (guild) { + for (const prop of Object.keys(message.d)) { + if (!["channels", "threads"].includes(prop)) { + guild[prop] = message.d[prop] + } + } + } + + } else if (message.t === "GUILD_EMOJIS_UPDATE") { + const guild = client.guilds.get(message.d.guild_id) + if (guild) { + guild.emojis = message.d.emojis + } + + } else if (message.t === "GUILD_STICKERS_UPDATE") { + const guild = client.guilds.get(message.d.guild_id) + if (guild) { + guild.stickers = message.d.stickers + } + + } else if (message.t === "GUILD_ROLE_CREATE" || message.t === "GUILD_ROLE_UPDATE" || message.t === "GUILD_ROLE_DELETE") { + const guild = client.guilds.get(message.d.guild_id) + /** Delete this in case of UPDATE or DELETE */ + const targetID = "role_id" in message.d ? message.d.role_id : message.d.role.id + /** Add this in case of CREATE or UPDATE */ + const newRoles = [] + if ("role" in message.d) newRoles.push(message.d.role) + if (guild) { + const targetIndex = guild.roles.findIndex(r => r.id === targetID) + if (targetIndex !== -1) { + // Role already exists. Delete it and maybe replace it. + guild.roles.splice(targetIndex, 1, ...newRoles) } else { - await homeserverStatus.homeserverStatus.waitForOnline(false) - } - - await eventDispatcher.checkMissedExpressions(message.d) - await eventDispatcher.checkMissedMessages(client, message.d) - await eventDispatcher.checkMissedPins(client, message.d) - await eventDispatcher.checkMissedLeaves(client, message.d) - } catch (e) { - console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:") - console.error(e) - } - } - - } else if (message.t === "GUILD_UPDATE") { - const guild = client.guilds.get(message.d.id) - if (guild) { - for (const prop of Object.keys(message.d)) { - if (!["channels", "threads"].includes(prop)) { - guild[prop] = message.d[prop] + // Role doesn't already exist. + guild.roles.push(...newRoles) } } - } - } else if (message.t === "GUILD_EMOJIS_UPDATE") { - const guild = client.guilds.get(message.d.guild_id) - if (guild) { - guild.emojis = message.d.emojis - } - - } else if (message.t === "GUILD_STICKERS_UPDATE") { - const guild = client.guilds.get(message.d.guild_id) - if (guild) { - guild.stickers = message.d.stickers - } - - } else if (message.t === "GUILD_ROLE_CREATE" || message.t === "GUILD_ROLE_UPDATE" || message.t === "GUILD_ROLE_DELETE") { - const guild = client.guilds.get(message.d.guild_id) - /** Delete this in case of UPDATE or DELETE */ - const targetID = "role_id" in message.d ? message.d.role_id : message.d.role.id - /** Add this in case of CREATE or UPDATE */ - const newRoles = [] - if ("role" in message.d) newRoles.push(message.d.role) - if (guild) { - const targetIndex = guild.roles.findIndex(r => r.id === targetID) - if (targetIndex !== -1) { - // Role already exists. Delete it and maybe replace it. - guild.roles.splice(targetIndex, 1, ...newRoles) - } else { - // Role doesn't already exist. - guild.roles.push(...newRoles) + } else if (message.t === "GUILD_MEMBER_UPDATE") { + const guild = client.guilds.get(message.d.guild_id) + const member = guild?.members.find(m => m.user.id === message.d.user.id) + if (member) { // only update existing members (i.e. the bot's own member) - don't want to inflate the cache with new irrelevant ones + Object.assign(member, message.d) } - } - } else if (message.t === "GUILD_MEMBER_UPDATE") { - const guild = client.guilds.get(message.d.guild_id) - const member = guild?.members.find(m => m.user.id === message.d.user.id) - if (member) { // only update existing members (i.e. the bot's own member) - don't want to inflate the cache with new irrelevant ones - Object.assign(member, message.d) - } + } else if (message.t === "THREAD_CREATE") { + client.channels.set(message.d.id, message.d) + if (message.d["guild_id"]) { + const channels = client.guildChannelMap.get(message.d["guild_id"]) + if (channels && !channels.includes(message.d.id)) channels.push(message.d.id) + } - } else if (message.t === "THREAD_CREATE") { - client.channels.set(message.d.id, message.d) - if (message.d["guild_id"]) { - const channels = client.guildChannelMap.get(message.d["guild_id"]) - if (channels && !channels.includes(message.d.id)) channels.push(message.d.id) - } - - } else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") { - client.channels.set(message.d.id, message.d) + } else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") { + client.channels.set(message.d.id, message.d) - } else if (message.t === "CHANNEL_PINS_UPDATE") { - const channel = client.channels.get(message.d.channel_id) - if (channel) { - channel["last_pin_timestamp"] = message.d.last_pin_timestamp - } + } else if (message.t === "CHANNEL_PINS_UPDATE") { + const channel = client.channels.get(message.d.channel_id) + if (channel) { + channel["last_pin_timestamp"] = message.d.last_pin_timestamp + } - } else if (message.t === "GUILD_DELETE") { - client.guilds.delete(message.d.id) - const channels = client.guildChannelMap.get(message.d.id) - if (channels) { - for (const id of channels) client.channels.delete(id) - } - client.guildChannelMap.delete(message.d.id) - - - } else if (message.t === "CHANNEL_CREATE") { - client.channels.set(message.d.id, message.d) - if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have - const channels = client.guildChannelMap.get(message.d["guild_id"]) - if (channels && !channels.includes(message.d.id)) channels.push(message.d.id) - } - - } else if (message.t === "CHANNEL_DELETE") { - client.channels.delete(message.d.id) - if (message.d["guild_id"]) { - const channels = client.guildChannelMap.get(message.d["guild_id"]) + } else if (message.t === "GUILD_DELETE") { + client.guilds.delete(message.d.id) + const channels = client.guildChannelMap.get(message.d.id) if (channels) { - const previous = channels.indexOf(message.d.id) - if (previous !== -1) channels.splice(previous, 1) + for (const id of channels) client.channels.delete(id) + } + client.guildChannelMap.delete(message.d.id) + + + } else if (message.t === "CHANNEL_CREATE") { + client.channels.set(message.d.id, message.d) + if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have + const channels = client.guildChannelMap.get(message.d["guild_id"]) + if (channels && !channels.includes(message.d.id)) channels.push(message.d.id) + } + + } else if (message.t === "CHANNEL_DELETE") { + client.channels.delete(message.d.id) + if (message.d["guild_id"]) { + const channels = client.guildChannelMap.get(message.d["guild_id"]) + if (channels) { + const previous = channels.indexOf(message.d.id) + if (previous !== -1) channels.splice(previous, 1) + } + } + } + + // Event dispatcher for OOYE bridge operations + if (listen === "full" && message.t) { + try { + if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") { + await eventDispatcher.onSomeReactionsRemoved(client, message.d) + + } else if (message.t === "INTERACTION_CREATE") { + await interactions.dispatchInteraction(message.d) + + } else if (message.t in eventDispatcher) { + await eventDispatcher[message.t](client, message.d) + } + } catch (e) { + // Let OOYE try to handle errors too + await eventDispatcher.onError(client, e, message) } } } - - // Event dispatcher for OOYE bridge operations - if (listen === "full" && message.t) { - const alwaysRealTimeEvents = ["PRESENCE_UPDATE"] - if (alwaysRealTimeEvents.includes(message.t) || homeserverStatus.homeserverStatus.isRealTime()) { - dispatchPacketToBridge(client, message) - } else { - homeserverStatus.homeserverStatus.queuePacket(message) - } - } } -/** - * @param {import("./discord-client")} client - * @param {import("cloudstorm").IGatewayMessage} message - */ -async function dispatchPacketToBridge(client, message) { - // requiring this later so that the client is already constructed by the time event-dispatcher is loaded - /** @type {typeof import("./event-dispatcher")} */ - const eventDispatcher = sync.require("./event-dispatcher") - /** @type {import("../discord/register-interactions")} */ - const interactions = sync.require("../discord/register-interactions") - - assert(message.t) // checked above - - try { - if (message.t === "MESSAGE_REACTION_REMOVE" || message.t === "MESSAGE_REACTION_REMOVE_EMOJI" || message.t === "MESSAGE_REACTION_REMOVE_ALL") { - await eventDispatcher.onSomeReactionsRemoved(client, message.d) - - } else if (message.t === "INTERACTION_CREATE") { - await interactions.dispatchInteraction(message.d) - - } else if (message.t in eventDispatcher) { - await eventDispatcher[message.t](client, message.d) - } - } catch (e) { - // Let OOYE try to handle errors too - await eventDispatcher.onError(client, e, message) - } -} - -module.exports.onPacket = onPacket -module.exports.dispatchPacketToBridge = dispatchPacketToBridge +module.exports = utils diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 31ef7ea..8101a03 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -60,7 +60,7 @@ module.exports = { matrixEventDispatcher.printError(gatewayMessage.t, "Discord", e, gatewayMessage) - const channelID = gatewayMessage.d?.["channel_id"] + const channelID = gatewayMessage.d["channel_id"] if (!channelID) return const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() if (!roomID) return @@ -313,7 +313,7 @@ module.exports = { if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable - const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id, message.author.id) + const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id) if (affected) return // @ts-ignore @@ -335,11 +335,13 @@ module.exports = { if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only! // Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from. - const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id, data.author.id) + const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id) if (affected) return - // Check that the sending-to room exists, and deal with Eventual Consistency(TM) - if (!await retrigger.waitForMessage(data.id)) return + if (!row) { + // Check that the sending-to room exists, and deal with Eventual Consistency(TM) + if (!await retrigger.waitForMessage(data.id)) return + } /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ // @ts-ignore diff --git a/src/discord/register-interactions.js b/src/discord/register-interactions.js index dc97287..e3d58c4 100644 --- a/src/discord/register-interactions.js +++ b/src/discord/register-interactions.js @@ -16,7 +16,7 @@ const ping = sync.require("./interactions/ping.js") // User must have EVERY permission in default_member_permissions to be able to use the command function registerInteractions() { - discord.snow.interaction.editApplicationCommands(id, [{ + discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{ name: "Matrix info", contexts: [DiscordTypes.InteractionContextType.Guild], type: DiscordTypes.ApplicationCommandType.Message, @@ -91,32 +91,40 @@ function registerInteractions() { async function dispatchInteraction(interaction) { const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"] try { - if (interactionId === "Matrix info") { - await matrixInfo.interact(interaction) - } else if (interactionId === "invite") { - await invite.interact(interaction) - } else if (interactionId === "invite_channel") { - await invite.interactButton(interaction) - } else if (interactionId === "Permissions") { - await permissions.interact(interaction) - } else if (interactionId === "permissions_edit") { - await permissions.interactEdit(interaction) - } else if (interactionId === "Responses") { - /** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore - const messageInteraction = interaction - if (select("poll", "message_id", {message_id: messageInteraction.data.target_id}).get()) { - await pollResponses.interact(messageInteraction) + if (interaction.type === DiscordTypes.InteractionType.MessageComponent || interaction.type === DiscordTypes.InteractionType.ModalSubmit) { + // All we get is custom_id, don't know which context the button was clicked in. + // So we namespace these ourselves in the custom_id. Currently the only existing namespace is POLL_. + if (interaction.data.custom_id.startsWith("POLL_")) { + await poll.interact(interaction) } else { - await reactions.interact(messageInteraction) + throw new Error(`Unknown message component ${interaction.data.custom_id}`) } - } else if (interactionId === "ping") { - await ping.interact(interaction) - } else if (interactionId === "privacy") { - await privacy.interact(interaction) - } else if (interactionId.startsWith("POLL_")) { - await poll.interact(interaction) } else { - throw new Error(`Unknown interaction ${interactionId}`) + if (interactionId === "Matrix info") { + await matrixInfo.interact(interaction) + } else if (interactionId === "invite") { + await invite.interact(interaction) + } else if (interactionId === "invite_channel") { + await invite.interactButton(interaction) + } else if (interactionId === "Permissions") { + await permissions.interact(interaction) + } else if (interactionId === "permissions_edit") { + await permissions.interactEdit(interaction) + } else if (interactionId === "Responses") { + /** @type {DiscordTypes.APIMessageApplicationCommandGuildInteraction} */ // @ts-ignore + const messageInteraction = interaction + if (select("poll", "message_id", {message_id: messageInteraction.data.target_id}).get()) { + await pollResponses.interact(messageInteraction) + } else { + await reactions.interact(messageInteraction) + } + } else if (interactionId === "ping") { + await ping.interact(interaction) + } else if (interactionId === "privacy") { + await privacy.interact(interaction) + } else { + throw new Error(`Unknown interaction ${interactionId}`) + } } } catch (e) { let stackLines = null diff --git a/src/discord/utils.js b/src/discord/utils.js index 6fb0b43..0d400f1 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -182,18 +182,6 @@ function filterTo(xs, fn) { return filtered } -/** - * The parameters correspond to the columns of the channel_room table. - * @param {string} rowChannelID thread ID, OR channel ID if there is no thread - * @param {string | null | undefined} rowThreadParent channel ID if there is a thread - */ -function swapThreadID(rowChannelID, rowThreadParent) { - return { - channelID: rowThreadParent ? rowThreadParent : rowChannelID, - threadID: rowThreadParent ? rowChannelID : undefined - } -} - const supportedPlaintextPreviewExtensions = new Set([ "4d", "abnf", @@ -594,5 +582,4 @@ module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact module.exports.getPublicUrlForCdn = getPublicUrlForCdn module.exports.howOldUnbridgedMessage = howOldUnbridgedMessage module.exports.filterTo = filterTo -module.exports.swapThreadID = swapThreadID module.exports.supportedPlaintextPreviewExtensions = supportedPlaintextPreviewExtensions diff --git a/src/m2d/actions/emoji-sheet.js b/src/m2d/actions/emoji-sheet.js index 077240c..ed5ab88 100644 --- a/src/m2d/actions/emoji-sheet.js +++ b/src/m2d/actions/emoji-sheet.js @@ -23,7 +23,7 @@ async function getAndConvertEmoji(mxc) { const res = await api.getMedia(mxc, {signal: abortController.signal}) if (res.status !== 200) { const root = await res.json() - throw new mreq.MatrixServerError(root, res.status, {mxc}) + throw new mreq.MatrixServerError(root, {mxc}) } const readable = stream.Readable.fromWeb(res.body) return emojiSheetConverter.convertImageStream(readable, () => { diff --git a/src/m2d/actions/sticker.js b/src/m2d/actions/sticker.js index f5a1e16..8eeb5d2 100644 --- a/src/m2d/actions/sticker.js +++ b/src/m2d/actions/sticker.js @@ -22,7 +22,7 @@ async function getAndResizeSticker(mxc) { const res = await api.getMedia(mxc) if (res.status !== 200) { const root = await res.json() - throw new mreq.MatrixServerError(root, res.status, {mxc}) + throw new mreq.MatrixServerError(root, {mxc}) } const streamIn = Readable.fromWeb(res.body) diff --git a/src/m2d/actions/update-pins.js b/src/m2d/actions/update-pins.js index 1b3f1bd..1ff2bb9 100644 --- a/src/m2d/actions/update-pins.js +++ b/src/m2d/actions/update-pins.js @@ -16,9 +16,9 @@ async function updatePins(pins, prev) { .select("reference_channel_id", "message_id").where({event_id}).and("ORDER BY part ASC").get() if (!row) continue if (added) { - discord.snow.channel.createChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix") + discord.snow.channel.addChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message pinned on Matrix") } else { - discord.snow.channel.deleteChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message unpinned on Matrix") + discord.snow.channel.removeChannelPinnedMessage(row.reference_channel_id, row.message_id, "Message unpinned on Matrix") } } } diff --git a/src/m2d/actions/vote.js b/src/m2d/actions/vote.js index 84f8cc7..926b957 100644 --- a/src/m2d/actions/vote.js +++ b/src/m2d/actions/vote.js @@ -1,12 +1,18 @@ // @ts-check const Ty = require("../../types") +const DiscordTypes = require("discord-api-types/v10") +const {Readable} = require("stream") const assert = require("assert").strict +const crypto = require("crypto") const passthrough = require("../../passthrough") -const {sync, db, select} = passthrough +const {sync, discord, db, select} = passthrough -/** @type {import("../../discord/utils")} */ -const dUtils = sync.require("../../discord/utils") +const {reg} = require("../../matrix/read-registration") +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("../../matrix/utils")} */ +const utils = sync.require("../../matrix/utils") /** @type {import("../converters/poll-components")} */ const pollComponents = sync.require("../converters/poll-components") /** @type {import("./channel-webhook")} */ @@ -27,11 +33,10 @@ async function updateVote(event) { // If poll was started on Matrix, the Discord version is using components, so we can update that to the current status if (messageRow.source === 0) { - const row = select("channel_room", ["channel_id", "thread_parent"], {room_id: event.room_id}).get() - assert(row) - const {channelID, threadID} = dUtils.swapThreadID(row.channel_id, row.thread_parent) - await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID), threadID) + const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() + assert(channelID) + await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID)) } } -module.exports.updateVote = updateVote +module.exports.updateVote = updateVote \ No newline at end of file diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index fbaf769..0a18a14 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -10,7 +10,6 @@ const domino = require("domino") const assert = require("assert").strict const entities = require("entities") const pb = require("prettier-bytes") -const mimeTypes = require("mime-types") const {tag} = require("@cloudrac3r/html-template-tag") const passthrough = require("../../passthrough") @@ -98,6 +97,15 @@ turndownService.addRule("underline", { } }) +turndownService.addRule("blockquote", { + filter: "blockquote", + replacement: function (content) { + content = content.replace(/^\n+|\n+$/g, "") + content = content.replace(/^/gm, "> ") + return content + } +}) + turndownService.addRule("spoiler", { filter: function (node, options) { return node.tagName === "SPAN" && node.hasAttribute("data-mx-spoiler") @@ -614,20 +622,13 @@ async function eventToMessage(event, guild, channel, di) { captionContent.addLine(`(Spoiler: ${fileSpoilerReason})`) } - // If filename is not specified, create one from mimetype - let filename = event.content.filename || event.content.body - if (!filename) { - const extension = mimeTypes.extension(event.content.info?.mimetype) - if (!extension) throw new Error("Filename and mimetype missing. Please report this bug to your client.") - filename = `file.${extension}` - } - // File link as alternative to uploading if (!("file" in event.content) && event.content.info?.size > getFileSizeForGuild(guild)) { // Upload (unencrypted) file as link, because it's too large for Discord // Do this by constructing a sample Matrix message with the link and then use the text processor to convert that + the original caption. const url = mxUtils.getPublicUrlForMxc(event.content.url) assert(url) + const filename = event.content.filename || event.content.body const emoji = attachmentEmojis.has(event.content.msgtype) ? attachmentEmojis.get(event.content.msgtype) + " " : "" if (fileIsSpoiler) { captionContent.addLine(`${emoji}Uploaded SPOILER file: <${url}> (${pb(event.content.info.size)})`, tag`${emoji}Uploaded SPOILER file: ${filename} (${pb(event.content.info.size)})`) // the space is necessary to work around a bug in Discord's URL previewer. the preview still gets blurred in the client. @@ -636,6 +637,7 @@ async function eventToMessage(event, guild, channel, di) { } } else { // Upload file as file + let filename = event.content.filename || event.content.body if (fileIsSpoiler) filename = "SPOILER_" + filename if ("file" in event.content) { // Encrypted @@ -853,7 +855,7 @@ async function eventToMessage(event, guild, channel, di) { input = input.replace(/("https:\/\/matrix.to.*?<\/a>):?/g, "$1") // Element adds a bunch of
before but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those. - input = input.replace(/(?:\n|
\s*)*<\/blockquote>(?:\n|
\s*)*/g, "") + input = input.replace(/(?:\n|
\s*)*<\/blockquote>/g, "") // The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason. // But I should not count it if it's between block elements. diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index 158c2ef..650e442 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -855,40 +855,6 @@ test("event2message: html lines are bridged correctly", async t => { ) }) -test("event2message: breaks between consecutive blockquotes are preserved", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "this was inspired by someone telling me\n\n>betrothed is an insane vocab pull\n\n> i think thats part of why im so scared of cadence\n\n> if an insult comes my way, not only do i not know when, but i'll have to humiliate myself and look it up", - format: "org.matrix.custom.html", - formatted_body: "

this was inspired by someone telling me

\n
\n

betrothed is an insane vocab pull

\n
\n
\n

i think thats part of why im so scared of cadence

\n
\n
\n

if an insult comes my way, not only do i not know when, but i'll have to humiliate myself and look it up

\n
" - }, - origin_server_ts: 1783571303430, - event_id: "$cdltlkLTUp-ma2uYmLKy7o3RkDgpDbtdYfngST_igWE", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - allowed_mentions: { - parse: ["users", "roles"] - }, - username: "cadence [they]", - avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", - content: "this was inspired by someone telling me" - + "\n\n> betrothed is an insane vocab pull" - + "\n\n> i think thats part of why im so scared of cadence" - + "\n\n> if an insult comes my way, not only do i not know when, but i'll have to humiliate myself and look it up" - }] - } - ) -}) - /*test("event2message: whitespace is retained", async t => { t.deepEqual( await eventToMessage({ @@ -1328,7 +1294,7 @@ test("event2message: characters are encoded properly in code blocks", async t => ) }) -test("event2message: quotes have an appropriate amount of whitespace (br following)", async t => { +test("event2message: quotes have an appropriate amount of whitespace", async t => { t.deepEqual( await eventToMessage({ content: { @@ -1362,40 +1328,6 @@ test("event2message: quotes have an appropriate amount of whitespace (br followi ) }) -test("event2message: quotes have an appropriate amount of whitespace (p following)", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: "
\n

short story

\n
\n

by whose standards? LOL

\n

I basically went all the way back to childhood (which analysts always like you to do)

\n
\n

laughed out loud

" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - ensureJoined: [], - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "> short story\nby whose standards? LOL\n\n> I basically went all the way back to childhood (which analysts always like you to do)\nlaughed out loud", - avatar_url: "https://bridge.example.org/download/letter-avatar?letter=C&hue=90", - allowed_mentions: { - parse: ["users", "roles"] - } - }] - } - ) -}) - test("event2message: lists have appropriate line breaks", async t => { t.deepEqual( await eventToMessage({ diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 129fca5..3580d1b 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -24,8 +24,6 @@ const vote = sync.require("./actions/vote") const matrixCommandHandler = sync.require("../matrix/matrix-command-handler") /** @type {import("../matrix/utils")} */ const utils = sync.require("../matrix/utils") -/** @type {import("../matrix/mreq")}) */ -const mreq = sync.require("../matrix/mreq") /** @type {import("../matrix/api")}) */ const api = sync.require("../matrix/api") /** @type {import("../d2m/actions/create-room")} */ @@ -34,8 +32,6 @@ const createRoom = sync.require("../d2m/actions/create-room") const roomUpgrade = require("../matrix/room-upgrade") /** @type {import("../d2m/actions/retrigger")} */ const retrigger = sync.require("../d2m/actions/retrigger") -/** @type {import("../matrix/homeserver-status")} */ -const homeserverStatus = sync.require("../matrix/homeserver-status") const {reg} = require("../matrix/read-registration") let lastReportedEvent = 0 @@ -170,12 +166,7 @@ async function sendError(roomID, source, type, e, payload) { key: "๐Ÿ”" } }) - } catch (e) { - if (e instanceof mreq.MatrixServerError && [502, 503].includes(e.httpStatus) && e.errcode === "CX_SERVER_ERROR") { - // Matrix homeserver is down (reverse proxy indicated failure) - homeserverStatus.homeserverStatus.setErrorWithPacket(payload) - } - } + } catch (e) {} } function guard(type, fn) { diff --git a/src/matrix/api.js b/src/matrix/api.js index 47fc565..9b7f280 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -452,8 +452,7 @@ async function ping() { headers: { Authorization: `Bearer ${reg.as_token}` }, - body: "{}", - signal: AbortSignal.timeout(15e3) + body: "{}" }) const root = await res.json() return { @@ -475,7 +474,7 @@ async function ping() { async function getMedia(mxc, init = {}) { init = {...init} - const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/([a-zA-Z0-9_-]+)$/) + const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) assert(mediaParts) let route = "download" diff --git a/src/matrix/homeserver-status.js b/src/matrix/homeserver-status.js deleted file mode 100644 index 3ceac84..0000000 --- a/src/matrix/homeserver-status.js +++ /dev/null @@ -1,134 +0,0 @@ -// @ts-check - -const assert = require("assert") -const Denque = require("denque") -const StateMachine = require("snowtransfer").StateMachine - -const passthrough = require("../passthrough") -const {sync} = passthrough -/** @type {import("../d2m/discord-packets")} */ -const discordPackets = sync.require("../d2m/discord-packets") -/** @type {import("../matrix/api")} */ -const api = sync.require("../matrix/api") - -const DEBUG_HOMESERVER_STATUS = false - -function debugHomeserverStatus(message) { - if (DEBUG_HOMESERVER_STATUS) { - console.log(message) - } -} - -const homeserverStatus = new class HomeserverStatus { - constructor() { - /** @private */ - this.queue = new Denque() - - /** @private */ - this.pingInterval = undefined - - /** @private */ - this.sm = new StateMachine("online") - .defineState("online") - - .defineState("checking", { - onEnter: [async () => { - const pingResult = await api.ping().catch(e => ({ok: false})) - if (pingResult.ok) { - this.sm.doTransition("check ok") - } else { - this.sm.doTransition("check fail") - } - }], - onLeave: [], - transitions: new Map() - }) - - .defineState("offline", { - onEnter: [() => { - this.pingInterval = setInterval(async () => { - const pingResult = await api.ping().catch(e => ({ok: false, status: "net", root: e.message})) - if (pingResult.ok) { - this.sm.doTransition("ping ok") - } - }, 15e3) - }], - onLeave: [() => { - clearInterval(this.pingInterval) - }], - transitions: new Map() - }) - - .defineState("recovering", { - onEnter: [async () => { // Drain queue. - while (!this.queue.isEmpty()) { - const packet = this.queue.peekFront() // same position as .shift() - debugHomeserverStatus(`homeserver status: ${new Date().toISOString()} dq packet ${packet.t} ${packet.d?.content}`) - await discordPackets.dispatchPacketToBridge(passthrough.discord, packet) - if (this.sm.currentStateName !== "recovering") return // got kicked out due to another error - this.queue.shift() - } - this.sm.doTransition("recovered") - }], - onLeave: [], - transitions: new Map() - }) - - .defineUniversalTransition("error", "offline") - .defineTransition("offline", "ping ok", "recovering") - .defineTransition("recovering", "recovered", "online") - .defineTransition("online", "check", "checking") - .defineTransition("checking", "check ok", "recovering") - .defineTransition("checking", "check fail", "offline") - - this.sm.on("enter", st => debugHomeserverStatus(`homeserver status: ${st}`)) - this.sm.setMaxListeners(101) - this.sm.freeze() - } - - isRealTime() { - return this.sm.currentStateName === "online" - } - - /** @param {boolean} forceCheck */ - waitForOnline(forceCheck) { - const onlinePromise = new Promise(resolve => { - // Already online? Start check or just done - if (this.sm.currentStateName === "online") { - if (forceCheck) { - this.sm.doTransition("check") - } else { - return resolve(null) - } - } - - // Checking or not online. Wait for online. - const onlineListener = stateName => { - if (stateName === "online") { - this.sm.removeListener("enter", onlineListener) - resolve(null) - } - } - this.sm.on("enter", onlineListener) - }) - return onlinePromise - } - - /** - * When offline or recovering, call this for incoming packets to queue them to be sent in order later. - */ - queuePacket(packet) { - assert(["offline", "recovering"].includes(this.sm.currentStateName)) - this.queue.push(packet) - } - - setErrorWithPacket(packet) { - const wasRecovering = this.sm.currentStateName === "recovering" - this.sm.doTransition("error") - if (!wasRecovering) { // if was recovering then packet is already in the right place in queue - this.queuePacket(packet) - } - } -} - -module.exports.homeserverStatus = homeserverStatus diff --git a/src/matrix/mreq.js b/src/matrix/mreq.js index bf238f2..bb59506 100644 --- a/src/matrix/mreq.js +++ b/src/matrix/mreq.js @@ -9,12 +9,9 @@ const {reg} = require("./read-registration.js") const baseUrl = `${reg.ooye.server_origin}/_matrix` class MatrixServerError extends Error { - /** @param {number} httpStatus} */ - constructor(data, httpStatus, opts) { + constructor(data, opts) { super(data.error || data.errcode) this.data = data - /** @type {number} */ - this.httpStatus = httpStatus /** @type {string} */ this.errcode = data.errcode this.opts = opts @@ -47,11 +44,11 @@ async function _convertBody(body) { async function makeMatrixServerError(res, opts = {}) { delete opts.headers?.["Authorization"] if (res.headers.get("content-type") === "application/json") { - return new MatrixServerError(await res.json(), res.status, opts) + return new MatrixServerError(await res.json(), opts) } else if (res.headers.get("content-type")?.startsWith("text/")) { - return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, message: await res.text()}, res.status, opts) + return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, message: await res.text()}, opts) } else { - return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, content_type: res.headers.get("content-type")}, res.status, opts) + return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, content_type: res.headers.get("content-type")}, opts) } } @@ -81,12 +78,12 @@ async function mreq(method, url, bodyIn, extra = {}) { var root = JSON.parse(text) } catch (e) { delete opts.headers?.["Authorization"] - throw new MatrixServerError(text, res.status, {baseUrl, url, ...opts}) + throw new MatrixServerError(text, {baseUrl, url, ...opts}) } if (!res.ok || root.errcode) { delete opts.headers?.["Authorization"] - throw new MatrixServerError(root, res.status, {baseUrl, url, ...opts}) + throw new MatrixServerError(root, {baseUrl, url, ...opts}) } return root } diff --git a/src/matrix/utils.js b/src/matrix/utils.js index fed71c9..eee635b 100644 --- a/src/matrix/utils.js +++ b/src/matrix/utils.js @@ -250,7 +250,7 @@ function getPublicUrlForMxc(mxc) { */ function makeMxcPublic(mxc) { assert(hasher, "xxhash is not ready yet") - const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/([a-zA-Z0-9_-]+)$/) + const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) if (!mediaParts) return undefined const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}` diff --git a/src/web/pug/guild.pug b/src/web/pug/guild.pug index 7411a1e..9791ae3 100644 --- a/src/web/pug/guild.pug +++ b/src/web/pug/guild.pug @@ -122,7 +122,7 @@ block body #role-add.s-popover(popover style="display: revert").ws2.px0.py4.bs-lg.overflow-visible .s-popover--arrow.s-popover--arrow__tc +add-roles-menu(guild, guild_id) - p.fc-light.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate. + p.fc-medium.mb0.mt8 Matrix users will start with these roles. If your main channels are gated by a role, use this to let Matrix users skip the gate. h3.mt32.fs-category Features .s-card.d-grid.px0.g16 @@ -191,14 +191,14 @@ block body label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id) +discord(channel, true, "Announcement") else - .s-empty-state.p8 No Discord channels available. + .s-empty-state.p8 All Discord channels are linked. .fl-grow1.s-btn-group.fd-column.w30 each room in unlinkedRooms input.s-btn--radio(type="radio" name="matrix" required id=room.room_id value=room.room_id) label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id) +matrix(room, true) else - .s-empty-state.p8 No Matrix rooms available. + .s-empty-state.p8 All Matrix rooms are linked. input(type="hidden" name="guild_id" value=guild_id) div button.s-btn.s-btn__icon.s-btn__filled#link-button diff --git a/start.js b/start.js index 51942c6..39e8ea0 100755 --- a/start.js +++ b/start.js @@ -31,9 +31,8 @@ sync.require("./src/m2d/event-dispatcher") ;(async () => { await migrate.migrate(db) - process.stdout.write("Connecting to Discord... ") await discord.cloud.connect() - console.log("ok.") + console.log("Discord gateway started") sync.require("./src/web/server") await power.applyPower() diff --git a/test/data.js b/test/data.js index eab9a63..f3092bc 100644 --- a/test/data.js +++ b/test/data.js @@ -5473,189 +5473,6 @@ module.exports = { content: '-# Original Message ID: 1466556003645657118 ยท ' } ] - }, - pk_ping_components_v1: { - type: 23, - content: "Psst, **Red** (<@772659086046658620>), you have been pinged by <@772659086046658620>.", - mentions: [ - { - id: "772659086046658620", - username: "cadence.worm", - avatar: "466df0c98b1af1e1388f595b4c1ad1b9", - discriminator: "0", - public_flags: 0, - flags: 0, - banner: null, - accent_color: null, - global_name: "cadence", - avatar_decoration_data: null, - collectibles: null, - display_name_styles: null, - banner_color: null, - clan: { - identity_guild_id: "532245108070809601", - identity_enabled: true, - tag: "doll", - badge: "dba08126b4e810a0e096cc7cd5bc37f0" - }, - primary_guild: { - identity_guild_id: "532245108070809601", - identity_enabled: true, - tag: "doll", - badge: "dba08126b4e810a0e096cc7cd5bc37f0" - } - } - ], - mention_roles: [], - attachments: [], - embeds: [], - timestamp: "2026-03-25T07:07:02.626000+00:00", - edited_timestamp: null, - flags: 0, - components: [ - { - type: 1, - id: 1, - components: [ - { - type: 2, - id: 2, - style: 5, - label: "Jump", - url: "https://discord.com/channels/1160893336324931584/1160894080998461480/1440549403667468320" - } - ] - } - ], - id: "1486260105908457653", - channel_id: "1160894080998461480", - author: { - id: "466378653216014359", - username: "PluralKit", - avatar: "b78ef67a081737a830b60aa47d9ebcd9", - discriminator: "4020", - public_flags: 65536, - flags: 65536, - bot: true, - banner: null, - accent_color: null, - global_name: null, - 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, - application_id: "466378653216014359", - interaction: { - id: "1486260103928614932", - type: 2, - name: "๐Ÿ”” Ping author", - user: { - id: "772659086046658620", - username: "cadence.worm", - avatar: "466df0c98b1af1e1388f595b4c1ad1b9", - discriminator: "0", - public_flags: 0, - flags: 0, - banner: null, - accent_color: null, - global_name: "cadence", - avatar_decoration_data: null, - collectibles: null, - display_name_styles: null, - banner_color: null, - clan: { - identity_guild_id: "532245108070809601", - identity_enabled: true, - tag: "doll", - badge: "dba08126b4e810a0e096cc7cd5bc37f0" - }, - primary_guild: { - identity_guild_id: "532245108070809601", - identity_enabled: true, - tag: "doll", - badge: "dba08126b4e810a0e096cc7cd5bc37f0" - } - } - }, - webhook_id: "466378653216014359", - message_reference: { - type: 0, - channel_id: "1160894080998461480", - message_id: "1440549403667468320", - guild_id: "1160893336324931584" - }, - interaction_metadata: { - id: "1486260103928614932", - type: 2, - user: { - id: "772659086046658620", - username: "cadence.worm", - avatar: "466df0c98b1af1e1388f595b4c1ad1b9", - discriminator: "0", - public_flags: 0, - flags: 0, - banner: null, - accent_color: null, - global_name: "cadence", - avatar_decoration_data: null, - collectibles: null, - display_name_styles: null, - banner_color: null, - clan: { - identity_guild_id: "532245108070809601", - identity_enabled: true, - tag: "doll", - badge: "dba08126b4e810a0e096cc7cd5bc37f0" - }, - primary_guild: { - identity_guild_id: "532245108070809601", - identity_enabled: true, - tag: "doll", - badge: "dba08126b4e810a0e096cc7cd5bc37f0" - } - }, - authorizing_integration_owners: { "0": "1160893336324931584" }, - name: "๐Ÿ”” Ping author", - command_type: 3, - target_message_id: "1440549403667468320" - }, - referenced_message: { - type: 0, - content: "test", - mentions: [], - mention_roles: [], - attachments: [], - embeds: [], - timestamp: "2025-11-19T03:49:01.948000+00:00", - edited_timestamp: null, - flags: 0, - components: [], - id: "1440549403667468320", - channel_id: "1160894080998461480", - author: { - id: "1195662438662680720", - username: "special name", - avatar: "a82347890f2739e5880cd82b8c1a708e", - 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: "466378653216014359", - webhook_id: "1195662438662680720" - } } }, message_update: { diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 1662320..8dd71cd 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -95,8 +95,7 @@ WITH a (message_id, channel_id) AS (VALUES ('1381212840957972480', '112760669178241024'), ('1401760355339862066', '112760669178241024'), ('1439351590262800565', '1438284564815548418'), -('1404133238414376971', '112760669178241024'), -('1440549403667468320', '1160894080998461480')) +('1404133238414376971', '112760669178241024')) SELECT message_id, max(historical_room_index) as historical_room_index FROM a INNER JOIN historical_channel_room ON historical_channel_room.reference_channel_id = a.channel_id GROUP BY message_id; INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES @@ -144,8 +143,7 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part ('$7P2O_VTQNHvavX5zNJ35DV-dbJB1Ag80tGQP_JzGdhk', 'm.room.message', 'm.text', '1401760355339862066', 0, 0, 0), ('$ielAnR6geu0P1Tl5UXfrbxlIf-SV9jrNprxrGXP3v7M', 'm.room.message', 'm.image', '1439351590262800565', 0, 0, 0), ('$uUKLcTQvik5tgtTGDKuzn0Ci4zcCvSoUcYn2X7mXm9I', 'm.room.message', 'm.text', '1404133238414376971', 0, 1, 1), -('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1), -('$l9FMmsEbh9K0NUReeEpWOMZYGRlUOE8yLcm6P-TYHSM', 'm.room.message', 'm.text', '1440549403667468320', 0, 0, 1); +('$LhmoWWvYyn5_AHkfb6FaXmLI6ZOC1kloql5P40YDmIk', 'm.room.message', 'm.notice', '1404133238414376971', 1, 0, 1); INSERT INTO file (discord_url, mxc_url) VALUES ('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),