diff --git a/.c8rc.json b/.c8rc.json new file mode 100644 index 0000000..5847f1a --- /dev/null +++ b/.c8rc.json @@ -0,0 +1,8 @@ +{ + "watermarks": { + "statements": [60, 100], + "lines": [60, 100], + "functions": [60, 100], + "branches": [60, 100] + } +} diff --git a/.gitignore b/.gitignore index 9a310ad..e533dce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,16 @@ -node_modules +# Secrets config.js registration.yaml +ooye.db* +events.db* + +# Automatically generated +node_modules coverage -db/ooye.db* +test/res/* +!test/res/lottie* +icon.svg +*~ +.#* +\#*# +launch.json diff --git a/addbot.js b/addbot.js old mode 100644 new mode 100755 index 667fbab..ef1cc63 --- a/addbot.js +++ b/addbot.js @@ -1,15 +1,18 @@ +#!/usr/bin/env node // @ts-check -const config = require("./config") +const {reg} = require("./src/matrix/read-registration") +const token = reg.ooye.discord_token +const id = Buffer.from(token.split(".")[0], "base64").toString() function addbot() { - const token = config.discordToken - const id = Buffer.from(token.split(".")[0], "base64") return `Open this link to add the bot to a Discord server:\nhttps://discord.com/oauth2/authorize?client_id=${id}&scope=bot&permissions=1610883072 ` } +/* c8 ignore next 3 */ if (process.argv.find(a => a.endsWith("addbot") || a.endsWith("addbot.js"))) { console.log(addbot()) } +module.exports.id = id module.exports.addbot = addbot diff --git a/addbot.sh b/addbot.sh index 6c3ff4b..d40d0da 100755 --- a/addbot.sh +++ b/addbot.sh @@ -1,3 +1,3 @@ #!/usr/bin/env sh echo "Open this link to add the bot to a Discord server:" -echo "https://discord.com/oauth2/authorize?client_id=$(grep discordToken config.js | sed -E 's!.*: ["'\'']([A-Za-z0-9+=/_-]*).*!\1!g' | base64 -d)&scope=bot&permissions=1610883072" +echo "https://discord.com/oauth2/authorize?client_id=$(grep discord_token registration.yaml | sed -E 's!.*: ["'\'']([A-Za-z0-9+=/_-]*).*!\1!g' | base64 -d)&scope=bot&permissions=1610883072" diff --git a/config.example.js b/config.example.js deleted file mode 100644 index 0d1a29e..0000000 --- a/config.example.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - discordToken: "yes" -} diff --git a/d2m/actions/create-room.js b/d2m/actions/create-room.js deleted file mode 100644 index 6994b57..0000000 --- a/d2m/actions/create-room.js +++ /dev/null @@ -1,414 +0,0 @@ -// @ts-check - -const assert = require("assert").strict -const DiscordTypes = require("discord-api-types/v10") -const reg = require("../../matrix/read-registration") - -const passthrough = require("../../passthrough") -const {discord, sync, db, select} = passthrough -/** @type {import("../../matrix/file")} */ -const file = sync.require("../../matrix/file") -/** @type {import("../../matrix/api")} */ -const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/kstate")} */ -const ks = sync.require("../../matrix/kstate") -/** @type {import("./create-space")}) */ -const createSpace = sync.require("./create-space") // watch out for the require loop - -/** - * There are 3 levels of room privacy: - * 0: Room is invite-only. - * 1: Anybody can use a link to join. - * 2: Room is published in room directory. - */ -const PRIVACY_ENUMS = { - PRESET: ["private_chat", "public_chat", "public_chat"], - VISIBILITY: ["private", "private", "public"], - SPACE_HISTORY_VISIBILITY: ["invited", "world_readable", "world_readable"], // copying from element client - ROOM_HISTORY_VISIBILITY: ["shared", "shared", "world_readable"], // any events sent after are visible, but for world_readable anybody can read without even joining - GUEST_ACCESS: ["can_join", "forbidden", "forbidden"], // whether guests can join space if other conditions are met - SPACE_JOIN_RULES: ["invite", "public", "public"], - ROOM_JOIN_RULES: ["restricted", "public", "public"] -} - -const DEFAULT_PRIVACY_LEVEL = 0 - -/** @type {Map>} channel ID -> Promise */ -const inflightRoomCreate = new Map() - -/** - * Async because it gets all room state from the homeserver. - * @param {string} roomID - */ -async function roomToKState(roomID) { - const root = await api.getAllState(roomID) - return ks.stateToKState(root) -} - -/** - * @param {string} roomID - * @param {any} kstate - */ -function applyKStateDiffToRoom(roomID, kstate) { - const events = ks.kstateToState(kstate) - return Promise.all(events.map(({type, state_key, content}) => - api.sendState(roomID, type, state_key, content) - )) -} - -/** - * @param {{id: string, name: string, topic?: string?, type: number}} channel - * @param {{id: string}} guild - * @param {string | null | undefined} customName - */ -function convertNameAndTopic(channel, guild, customName) { - let channelPrefix = - ( channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] " - : channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] " - : channel.type === DiscordTypes.ChannelType.GuildVoice ? "[🔊] " - : "") - const chosenName = customName || (channelPrefix + channel.name); - const maybeTopicWithPipe = channel.topic ? ` | ${channel.topic}` : ''; - const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : ''; - const channelIDPart = `Channel ID: ${channel.id}`; - const guildIDPart = `Guild ID: ${guild.id}`; - - const convertedTopic = customName - ? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${guildIDPart}` - : `${maybeTopicWithNewlines}${channelIDPart}\n${guildIDPart}`; - - return [chosenName, convertedTopic]; -} - -/** - * Async because it may create the guild and/or upload the guild icon to mxc. - * @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel - * @param {DiscordTypes.APIGuild} guild - */ -async function channelToKState(channel, guild) { - const spaceID = await createSpace.ensureSpace(guild) - assert(typeof spaceID === "string") - const privacyLevel = select("guild_space", "privacy_level", {space_id: spaceID}).pluck().get() - assert(typeof privacyLevel === "number") - - const row = select("channel_room", ["nick", "custom_avatar"], {channel_id: channel.id}).get() - const customName = row?.nick - const customAvatar = row?.custom_avatar - const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName) - - const avatarEventContent = {} - if (customAvatar) { - avatarEventContent.url = customAvatar - } else if (guild.icon) { - avatarEventContent.discord_path = file.guildIcon(guild) - avatarEventContent.url = await file.uploadDiscordFileToMxc(avatarEventContent.discord_path) // TODO: somehow represent future values in kstate (callbacks?), while still allowing for diffing, so test cases don't need to touch the media API - } - - let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel] - if (channel["thread_metadata"]) history_visibility = "world_readable" - - /** @type {{join_rule: string, allow?: any}} */ - let join_rules = { - join_rule: "restricted", - allow: [{ - type: "m.room_membership", - room_id: spaceID - }] - } - if (PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel] !== "restricted") { - join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} - } - - const channelKState = { - "m.room.name/": {name: convertedName}, - "m.room.topic/": {topic: convertedTopic}, - "m.room.avatar/": avatarEventContent, - "m.room.guest_access/": {guest_access: PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]}, - "m.room.history_visibility/": {history_visibility}, - [`m.space.parent/${spaceID}`]: { - via: [reg.ooye.server_name], - canonical: true - }, - /** @type {{join_rule: string, [x: string]: any}} */ - "m.room.join_rules/": join_rules, - "m.room.power_levels/": { - events: { - "m.room.avatar": 0 - }, - users: reg.ooye.invite.reduce((a, c) => (a[c] = 100, a), {}) - }, - "chat.schildi.hide_ui/read_receipts": { - hidden: true - }, - [`uk.half-shot.bridge/moe.cadence.ooye://discord/${guild.id}/${channel.id}`]: { - bridgebot: `@${reg.sender_localpart}:${reg.ooye.server_name}`, - protocol: { - id: "discord", - displayname: "Discord" - }, - network: { - id: guild.id, - displayname: guild.name, - avatar_url: await file.uploadDiscordFileToMxc(file.guildIcon(guild)) - }, - channel: { - id: channel.id, - displayname: channel.name, - external_url: `https://discord.com/channels/${guild.id}/${channel.id}` - } - } - } - - return {spaceID, privacyLevel, channelKState} -} - -/** - * Create a bridge room, store the relationship in the database, and add it to the guild's space. - * @param {DiscordTypes.APIGuildTextChannel} channel - * @param guild - * @param {string} spaceID - * @param {any} kstate - * @param {number} privacyLevel - * @returns {Promise} room ID - */ -async function createRoom(channel, guild, spaceID, kstate, privacyLevel) { - let threadParent = null - if (channel.type === DiscordTypes.ChannelType.PublicThread) threadParent = channel.parent_id - - // Name and topic can be done earlier in room creation rather than in initial_state - // https://spec.matrix.org/latest/client-server-api/#creation - const name = kstate["m.room.name/"].name - delete kstate["m.room.name/"] - assert(name) - const topic = kstate["m.room.topic/"].topic - delete kstate["m.room.topic/"] - assert(topic) - - const roomID = await postApplyPowerLevels(kstate, async kstate => { - const roomID = await api.createRoom({ - name, - topic, - preset: PRIVACY_ENUMS.PRESET[privacyLevel], // This is closest to what we want, but properties from kstate override it anyway - visibility: PRIVACY_ENUMS.VISIBILITY[privacyLevel], - invite: [], - initial_state: ks.kstateToState(kstate) - }) - - db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES (?, ?, ?, NULL, ?)").run(channel.id, roomID, channel.name, threadParent) - - return roomID - }) - - // Put the newly created child into the space, no need to await this - _syncSpaceMember(channel, spaceID, roomID) - - return roomID -} - -/** - * Handling power levels separately. The spec doesn't specify what happens, Dendrite differs, - * and Synapse does an absolutely insane *shallow merge* of what I provide on top of what it creates. - * We don't want the `events` key to be overridden completely. - * https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210 - * https://github.com/matrix-org/matrix-spec/issues/492 - * @param {any} kstate - * @param {(_: any) => Promise} callback must return room ID - * @returns {Promise} room ID - */ -async function postApplyPowerLevels(kstate, callback) { - const powerLevelContent = kstate["m.room.power_levels/"] - const kstateWithoutPowerLevels = {...kstate} - delete kstateWithoutPowerLevels["m.room.power_levels/"] - - /** @type {string} */ - const roomID = await callback(kstateWithoutPowerLevels) - - // Now *really* apply the power level overrides on top of what Synapse *really* set - if (powerLevelContent) { - const newRoomKState = await roomToKState(roomID) - const newRoomPowerLevelsDiff = ks.diffKState(newRoomKState, {"m.room.power_levels/": powerLevelContent}) - await applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff) - } - - return roomID -} - -/** - * @param {DiscordTypes.APIGuildChannel} channel - */ -function channelToGuild(channel) { - const guildID = channel.guild_id - assert(guildID) - const guild = discord.guilds.get(guildID) - assert(guild) - return guild -} - -/* - Ensure flow: - 1. Get IDs - 2. Does room exist? If so great! - (it doesn't, so it needs to be created) - 3. Get kstate for channel - 4. Create room, return new ID - - Ensure + sync flow: - 1. Get IDs - 2. Does room exist? - 2.5: If room does exist AND wasn't asked to sync: return here - 3. Get kstate for channel - 4. Create room with kstate if room doesn't exist - 5. Get and update room state with kstate if room does exist -*/ - -/** - * @param {string} channelID - * @param {boolean} shouldActuallySync false if just need to ensure room exists (which is a quick database check), true if also want to sync room data when it does exist (slow) - * @returns {Promise} room ID - */ -async function _syncRoom(channelID, shouldActuallySync) { - /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ - const channel = discord.channels.get(channelID) - assert.ok(channel) - const guild = channelToGuild(channel) - - if (inflightRoomCreate.has(channelID)) { - await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it - } - - const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channelID}).get() - - if (!existing) { - const creation = (async () => { - const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild) - const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel) - inflightRoomCreate.delete(channelID) // OK to release inflight waiters now. they will read the correct `existing` row - return roomID - })() - inflightRoomCreate.set(channelID, creation) - return creation // Naturally, the newly created room is already up to date, so we can always skip syncing here. - } - - const roomID = existing.room_id - - if (!shouldActuallySync) { - return existing.room_id // only need to ensure room exists, and it does. return the room ID - } - - console.log(`[room sync] to matrix: ${channel.name}`) - - const {spaceID, channelKState} = await channelToKState(channel, guild) // calling this in both branches because we don't want to calculate this if not syncing - - // sync channel state to room - const roomKState = await roomToKState(roomID) - if (+roomKState["m.room.create/"].room_version <= 8) { - // join_rule `restricted` is not available in room version < 8 and not working properly in version == 8 - // read more: https://spec.matrix.org/v1.8/rooms/v9/ - // we have to use `public` instead, otherwise the room will be unjoinable. - channelKState["m.room.join_rules/"] = {join_rule: "public"} - } - const roomDiff = ks.diffKState(roomKState, channelKState) - const roomApply = applyKStateDiffToRoom(roomID, roomDiff) - db.prepare("UPDATE channel_room SET name = ? WHERE room_id = ?").run(channel.name, roomID) - - // sync room as space member - const spaceApply = _syncSpaceMember(channel, spaceID, roomID) - await Promise.all([roomApply, spaceApply]) - - return roomID -} - -/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. */ -function ensureRoom(channelID) { - return _syncRoom(channelID, false) -} - -/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. */ -function syncRoom(channelID) { - return _syncRoom(channelID, true) -} - -async function _unbridgeRoom(channelID) { - /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ - const channel = discord.channels.get(channelID) - assert.ok(channel) - return unbridgeDeletedChannel(channel.id, channel.guild_id) -} - -async function unbridgeDeletedChannel(channelID, guildID) { - const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() - assert.ok(roomID) - const spaceID = select("guild_space", "space_id", {guild_id: guildID}).pluck().get() - assert.ok(spaceID) - - // remove room from being a space member - await api.sendState(roomID, "m.space.parent", spaceID, {}) - await api.sendState(spaceID, "m.space.child", roomID, {}) - - // remove declaration that the room is bridged - await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channelID}`, {}) - - // send a notification in the room - await api.sendEvent(roomID, "m.room.message", { - msgtype: "m.notice", - body: "⚠️ This room was removed from the bridge." - }) - - // leave room - await api.leaveRoom(roomID) - - // delete room from database - const {changes} = db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channelID) - assert.equal(changes, 1) -} - -/** - * Async because it gets all space state from the homeserver, then if necessary sends one state event back. - * @param {DiscordTypes.APIGuildTextChannel} channel - * @param {string} spaceID - * @param {string} roomID - * @returns {Promise} - */ -async function _syncSpaceMember(channel, spaceID, roomID) { - const spaceKState = await roomToKState(spaceID) - let spaceEventContent = {} - if ( - channel.type !== DiscordTypes.ChannelType.PrivateThread // private threads do not belong in the space (don't offer people something they can't join) - && !channel["thread_metadata"]?.archived // archived threads do not belong in the space (don't offer people conversations that are no longer relevant) - ) { - spaceEventContent = { - via: [reg.ooye.server_name] - } - } - const spaceDiff = ks.diffKState(spaceKState, { - [`m.space.child/${roomID}`]: spaceEventContent - }) - return applyKStateDiffToRoom(spaceID, spaceDiff) -} - -async function createAllForGuild(guildID) { - const channelIDs = discord.guildChannelMap.get(guildID) - assert.ok(channelIDs) - for (const channelID of channelIDs) { - const allowedTypes = [DiscordTypes.ChannelType.GuildText, DiscordTypes.ChannelType.PublicThread] - // @ts-ignore - if (allowedTypes.includes(discord.channels.get(channelID)?.type)) { - const roomID = await syncRoom(channelID) - console.log(`synced ${channelID} <-> ${roomID}`) - } - } -} - -module.exports.DEFAULT_PRIVACY_LEVEL = DEFAULT_PRIVACY_LEVEL -module.exports.PRIVACY_ENUMS = PRIVACY_ENUMS -module.exports.createRoom = createRoom -module.exports.ensureRoom = ensureRoom -module.exports.syncRoom = syncRoom -module.exports.createAllForGuild = createAllForGuild -module.exports.channelToKState = channelToKState -module.exports.roomToKState = roomToKState -module.exports.applyKStateDiffToRoom = applyKStateDiffToRoom -module.exports.postApplyPowerLevels = postApplyPowerLevels -module.exports._convertNameAndTopic = convertNameAndTopic -module.exports._unbridgeRoom = _unbridgeRoom -module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel diff --git a/d2m/actions/create-room.test.js b/d2m/actions/create-room.test.js deleted file mode 100644 index 93f9203..0000000 --- a/d2m/actions/create-room.test.js +++ /dev/null @@ -1,89 +0,0 @@ -// @ts-check - -const {channelToKState, _convertNameAndTopic} = require("./create-room") -const {kstateStripConditionals} = require("../../matrix/kstate") -const {test} = require("supertape") -const testData = require("../../test/data") - -const passthrough = require("../../passthrough") -const {db} = passthrough - -test("channel2room: discoverable privacy room", async t => { - db.prepare("UPDATE guild_space SET privacy_level = 2").run() - t.deepEqual( - kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)), - Object.assign({}, testData.room.general, { - "m.room.guest_access/": {guest_access: "forbidden"}, - "m.room.join_rules/": {join_rule: "public"}, - "m.room.history_visibility/": {history_visibility: "world_readable"} - }) - ) -}) - -test("channel2room: linkable privacy room", async t => { - db.prepare("UPDATE guild_space SET privacy_level = 1").run() - t.deepEqual( - kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)), - Object.assign({}, testData.room.general, { - "m.room.guest_access/": {guest_access: "forbidden"}, - "m.room.join_rules/": {join_rule: "public"} - }) - ) -}) - -test("channel2room: invite-only privacy room", async t => { - db.prepare("UPDATE guild_space SET privacy_level = 0").run() - t.deepEqual( - kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general).then(x => x.channelKState)), - testData.room.general - ) -}) - -test("convertNameAndTopic: custom name and topic", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"), - ["hauntings", "#the-twilight-zone | Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: custom name, no topic", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, "hauntings"), - ["hauntings", "#the-twilight-zone\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: original name and topic", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, null), - ["the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: original name, no topic", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, null), - ["the-twilight-zone", "Channel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: public thread icon", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 11}, {id: "456"}, null), - ["[⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: private thread icon", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 12}, {id: "456"}, null), - ["[🔒⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) - -test("convertNameAndTopic: voice channel icon", t => { - t.deepEqual( - _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 2}, {id: "456"}, null), - ["[🔊] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] - ) -}) diff --git a/d2m/actions/delete-message.js b/d2m/actions/delete-message.js deleted file mode 100644 index 30c31d4..0000000 --- a/d2m/actions/delete-message.js +++ /dev/null @@ -1,23 +0,0 @@ -// @ts-check - -const passthrough = require("../../passthrough") -const {sync, db, select} = passthrough -/** @type {import("../../matrix/api")} */ -const api = sync.require("../../matrix/api") - -/** - * @param {import("discord-api-types/v10").GatewayMessageDeleteDispatchData} data - */ -async function deleteMessage(data) { - const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get() - if (!roomID) return - - const eventsToRedact = select("event_message", "event_id", {message_id: data.id}).pluck().all() - for (const eventID of eventsToRedact) { - // Unfortunately, we can't specify a sender to do the redaction as, unless we find out that info via the audit logs - await api.redactEvent(roomID, eventID) - db.prepare("DELETE FROM event_message WHERE event_id = ?").run(eventID) - } -} - -module.exports.deleteMessage = deleteMessage diff --git a/d2m/actions/register-user.js b/d2m/actions/register-user.js deleted file mode 100644 index 9b5527f..0000000 --- a/d2m/actions/register-user.js +++ /dev/null @@ -1,185 +0,0 @@ -// @ts-check - -const assert = require("assert") -const reg = require("../../matrix/read-registration") - -const passthrough = require("../../passthrough") -const {discord, sync, db, select} = passthrough -/** @type {import("../../matrix/api")} */ -const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/file")} */ -const file = sync.require("../../matrix/file") -/** @type {import("../converters/user-to-mxid")} */ -const userToMxid = sync.require("../converters/user-to-mxid") -/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore -let hasher = null -// @ts-ignore -require("xxhash-wasm")().then(h => hasher = h) - -/** - * A sim is an account that is being simulated by the bridge to copy events from the other side. - * @param {import("discord-api-types/v10").APIUser} user - * @returns mxid - */ -async function createSim(user) { - // Choose sim name - const simName = userToMxid.userToSimName(user) - const localpart = reg.ooye.namespace_prefix + simName - const mxid = `@${localpart}:${reg.ooye.server_name}` - - // Save chosen name in the database forever - // Making this database change right away so that in a concurrent registration, the 2nd registration will already have generated a different localpart because it can see this row when it generates - db.prepare("INSERT INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(user.id, simName, localpart, mxid) - - // Register matrix user with that name - try { - await api.register(localpart) - } catch (e) { - // If user creation fails, manually undo the database change. Still isn't perfect, but should help. - // (A transaction would be preferable, but I don't think it's safe to leave transaction open across event loop ticks.) - db.prepare("DELETE FROM sim WHERE user_id = ?").run(user.id) - throw e - } - return mxid -} - -/** - * Ensure a sim is registered for the user. - * If there is already a sim, use that one. If there isn't one yet, register a new sim. - * @param {import("discord-api-types/v10").APIUser} user - * @returns {Promise} mxid - */ -async function ensureSim(user) { - let mxid = null - const existing = select("sim", "mxid", {user_id: user.id}).pluck().get() - if (existing) { - mxid = existing - } else { - mxid = await createSim(user) - } - return mxid -} - -/** - * Ensure a sim is registered for the user and is joined to the room. - * @param {import("discord-api-types/v10").APIUser} user - * @param {string} roomID - * @returns {Promise} mxid - */ -async function ensureSimJoined(user, roomID) { - // Ensure room ID is really an ID, not an alias - assert.ok(roomID[0] === "!") - - // Ensure user - const mxid = await ensureSim(user) - - // Ensure joined - const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get() - if (!existing) { - try { - await api.inviteToRoom(roomID, mxid) - await api.joinRoom(roomID, mxid) - } catch (e) { - if (e.message.includes("is already in the room.")) { - // Sweet! - } else { - throw e - } - } - db.prepare("INSERT OR IGNORE INTO sim_member (room_id, mxid) VALUES (?, ?)").run(roomID, mxid) - } - return mxid -} - -/** - * @param {import("discord-api-types/v10").APIUser} user - * @param {Omit} member - */ -async function memberToStateContent(user, member, guildID) { - let displayname = user.username - if (user.global_name) displayname = user.global_name - if (member.nick) displayname = member.nick - - const content = { - displayname, - membership: "join", - "moe.cadence.ooye.member": { - }, - "uk.half-shot.discord.member": { - bot: !!user.bot, - displayColor: user.accent_color, - id: user.id, - username: user.discriminator.length === 4 ? `${user.username}#${user.discriminator}` : `@${user.username}` - } - } - - if (member.avatar || user.avatar) { - // const avatarPath = file.userAvatar(user) // the user avatar only - const avatarPath = file.memberAvatar(guildID, user, member) // the member avatar or the user avatar - content["moe.cadence.ooye.member"].avatar = avatarPath - content.avatar_url = await file.uploadDiscordFileToMxc(avatarPath) - } - - return content -} - -function hashProfileContent(content) { - const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}`) - const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range - return signedHash -} - -/** - * Sync profile data for a sim user. This function follows the following process: - * 1. Join the sim to the room if needed - * 2. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before - * 3. Compare against the previously known state content, which is helpfully stored in the database - * 4. If the state content has changed, send it to Matrix and update it in the database for next time - * @param {import("discord-api-types/v10").APIUser} user - * @param {Omit} member - * @returns {Promise} mxid of the updated sim - */ -async function syncUser(user, member, guildID, roomID) { - const mxid = await ensureSimJoined(user, roomID) - const content = await memberToStateContent(user, member, guildID) - const currentHash = hashProfileContent(content) - const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() - // only do the actual sync if the hash has changed since we last looked - if (existingHash !== currentHash) { - await api.sendState(roomID, "m.room.member", mxid, content, mxid) - db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) - } - return mxid -} - -async function syncAllUsersInRoom(roomID) { - const mxids = select("sim_member", "mxid", {room_id: roomID}).pluck().all() - - const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get() - assert.ok(typeof channelID === "string") - - /** @ts-ignore @type {import("discord-api-types/v10").APIGuildChannel} */ - const channel = discord.channels.get(channelID) - const guildID = channel.guild_id - assert.ok(typeof guildID === "string") - - for (const mxid of mxids) { - const userID = select("sim", "user_id", {mxid}).pluck().get() - assert.ok(typeof userID === "string") - - /** @ts-ignore @type {Required} */ - const member = await discord.snow.guild.getGuildMember(guildID, userID) - /** @ts-ignore @type {Required} user */ - const user = member.user - assert.ok(user) - - console.log(`[user sync] to matrix: ${user.username} in ${channel.name}`) - await syncUser(user, member, guildID, roomID) - } -} - -module.exports._memberToStateContent = memberToStateContent -module.exports.ensureSim = ensureSim -module.exports.ensureSimJoined = ensureSimJoined -module.exports.syncUser = syncUser -module.exports.syncAllUsersInRoom = syncAllUsersInRoom diff --git a/d2m/actions/register-user.test.js b/d2m/actions/register-user.test.js deleted file mode 100644 index 96c73aa..0000000 --- a/d2m/actions/register-user.test.js +++ /dev/null @@ -1,63 +0,0 @@ -const {_memberToStateContent} = require("./register-user") -const {test} = require("supertape") -const testData = require("../../test/data") - -test("member2state: without member nick or avatar", async t => { - t.deepEqual( - await _memberToStateContent(testData.member.kumaccino.user, testData.member.kumaccino, testData.guild.general.id), - { - avatar_url: "mxc://cadence.moe/UpAeIqeclhKfeiZNdIWNcXXL", - displayname: "kumaccino", - membership: "join", - "moe.cadence.ooye.member": { - avatar: "/avatars/113340068197859328/b48302623a12bc7c59a71328f72ccb39.png?size=1024" - }, - "uk.half-shot.discord.member": { - bot: false, - displayColor: 10206929, - id: "113340068197859328", - username: "@kumaccino" - } - } - ) -}) - -test("member2state: with global name, without member nick or avatar", async t => { - t.deepEqual( - await _memberToStateContent(testData.member.papiophidian.user, testData.member.papiophidian, testData.guild.general.id), - { - avatar_url: "mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX", - displayname: "PapiOphidian", - membership: "join", - "moe.cadence.ooye.member": { - avatar: "/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024" - }, - "uk.half-shot.discord.member": { - bot: false, - displayColor: 1579292, - id: "320067006521147393", - username: "@papiophidian" - } - } - ) -}) - -test("member2state: with member nick and avatar", async t => { - t.deepEqual( - await _memberToStateContent(testData.member.sheep.user, testData.member.sheep, testData.guild.general.id), - { - avatar_url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl", - displayname: "The Expert's Submarine", - membership: "join", - "moe.cadence.ooye.member": { - avatar: "/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024" - }, - "uk.half-shot.discord.member": { - bot: false, - displayColor: null, - id: "134826546694193153", - username: "@aprilsong" - } - } - ) -}) diff --git a/d2m/actions/update-pins.js b/d2m/actions/update-pins.js deleted file mode 100644 index 40cc358..0000000 --- a/d2m/actions/update-pins.js +++ /dev/null @@ -1,22 +0,0 @@ -// @ts-check - -const passthrough = require("../../passthrough") -const {discord, sync} = passthrough -/** @type {import("../converters/pins-to-list")} */ -const pinsToList = sync.require("../converters/pins-to-list") -/** @type {import("../../matrix/api")} */ -const api = sync.require("../../matrix/api") - -/** - * @param {string} channelID - * @param {string} roomID - */ -async function updatePins(channelID, roomID) { - const pins = await discord.snow.channel.getChannelPinnedMessages(channelID) - const eventIDs = pinsToList.pinsToList(pins) - await api.sendState(roomID, "m.room.pinned_events", "", { - pinned: eventIDs - }) -} - -module.exports.updatePins = updatePins diff --git a/d2m/converters/edit-to-changes.js b/d2m/converters/edit-to-changes.js deleted file mode 100644 index f86921c..0000000 --- a/d2m/converters/edit-to-changes.js +++ /dev/null @@ -1,153 +0,0 @@ -// @ts-check - -const assert = require("assert").strict - -const passthrough = require("../../passthrough") -const {discord, sync, db, select, from} = passthrough -/** @type {import("./message-to-event")} */ -const messageToEvent = sync.require("../converters/message-to-event") -/** @type {import("../actions/register-user")} */ -const registerUser = sync.require("../actions/register-user") -/** @type {import("../actions/create-room")} */ -const createRoom = sync.require("../actions/create-room") - -/** - * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message - * IMPORTANT: This may not have all the normal fields! The API documentation doesn't provide possible types, just says it's all optional! - * Since I don't have a spec, I will have to capture some real traffic and add it as test cases... I hope they don't change anything later... - * @param {import("discord-api-types/v10").APIGuild} guild - * @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API - */ -async function editToChanges(message, guild, api) { - // Figure out what events we will be replacing - - const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() - assert(roomID) - /** @type {string?} Null if we don't have a sender in the room, which will happen if it's a webhook's message. The bridge bot will do the edit instead. */ - const senderMxid = from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get() || null - - const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all() - - // Figure out what we will be replacing them with - - const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api}) - const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api}) - assert.ok(newFallbackContent.length === newInnerContent.length) - - // Match the new events to the old events - - /* - Rules: - + The events must have the same type. - + The events must have the same subtype. - Events will therefore be divided into four categories: - */ - /** 1. Events that are matched, and should be edited by sending another m.replace event */ - let eventsToReplace = [] - /** 2. Events that are present in the old version only, and should be blanked or redacted */ - let eventsToRedact = [] - /** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */ - let eventsToSend = [] - // 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. This is represented as nothing. - - function shift() { - newFallbackContent.shift() - newInnerContent.shift() - } - - // For each old event... - outer: while (newFallbackContent.length) { - const newe = newFallbackContent[0] - // Find a new event to pair it with... - for (let i = 0; i < oldEventRows.length; i++) { - const olde = oldEventRows[i] - if (olde.event_type === newe.$type && olde.event_subtype === (newe.msgtype || null)) { // The spec does allow subtypes to change, so I can change this condition later if I want to - // Found one! - // Set up the pairing - eventsToReplace.push({ - old: olde, - newFallbackContent: newFallbackContent[0], - newInnerContent: newInnerContent[0] - }) - // These events have been handled now, so remove them from the source arrays - shift() - oldEventRows.splice(i, 1) - // Go all the way back to the start of the next iteration of the outer loop - continue outer - } - } - // If we got this far, we could not pair it to an existing event, so it'll have to be a new one - eventsToSend.push(newInnerContent[0]) - shift() - } - // Anything remaining in oldEventRows is present in the old version only and should be redacted. - eventsToRedact = oldEventRows - - // We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times. - /** @type {({column: string, eventID: string} | {column: string, nextEvent: true})[]} */ - const promotions = [] - for (const column of ["part", "reaction_part"]) { - // If no events with part = 0 exist (or will exist), we need to do some management. - if (!eventsToReplace.some(e => e.old[column] === 0)) { - if (eventsToReplace.length) { - // We can choose an existing event to promote. Bigger order is better. - const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.event_subtype === "m.text") - eventsToReplace.sort((a, b) => order(b) - order(a)) - promotions.push({column, eventID: eventsToReplace[0].old.event_id}) - } else { - // No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0. - promotions.push({column, nextEvent: true}) - } - } - } - - // Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed! - // (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.) - // So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed. - eventsToReplace = eventsToReplace.filter(ev => { - // Discord does not allow files, images, attachments, or videos to be edited. - if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") { - return false - } - // Discord does not allow stickers to be edited. - if (ev.old.event_type === "m.sticker") { - return false - } - // Anything else is fair game. - return true - }) - - // Removing unnecessary properties before returning - eventsToRedact = eventsToRedact.map(e => e.event_id) - eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)})) - - return {roomID, eventsToReplace, eventsToRedact, eventsToSend, senderMxid, promotions} -} - -/** - * @template T - * @param {string} oldID - * @param {T} newFallbackContent - * @param {T} newInnerContent - * @returns {import("../../types").Event.ReplacementContent} content - */ -function makeReplacementEventContent(oldID, newFallbackContent, newInnerContent) { - const content = { - ...newFallbackContent, - "m.mentions": {}, - "m.new_content": { - ...newInnerContent - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: oldID - } - } - delete content["m.new_content"]["$type"] - // Client-Server API spec 11.37.3: Any m.relates_to property within m.new_content is ignored. - delete content["m.new_content"]["m.relates_to"] - return content -} - -module.exports.editToChanges = editToChanges -module.exports.makeReplacementEventContent = makeReplacementEventContent diff --git a/d2m/converters/edit-to-changes.test.js b/d2m/converters/edit-to-changes.test.js deleted file mode 100644 index 7c29787..0000000 --- a/d2m/converters/edit-to-changes.test.js +++ /dev/null @@ -1,177 +0,0 @@ -const {test} = require("supertape") -const {editToChanges} = require("./edit-to-changes") -const data = require("../../test/data") -const Ty = require("../../types") - -test("edit2changes: edit by webhook", async t => { - const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, {}) - t.deepEqual(eventsToRedact, []) - t.deepEqual(eventsToSend, []) - t.deepEqual(eventsToReplace, [{ - oldID: "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10", - newContent: { - $type: "m.room.message", - msgtype: "m.text", - body: "* test 2", - "m.mentions": {}, - "m.new_content": { - // *** Replaced With: *** - msgtype: "m.text", - body: "test 2", - "m.mentions": {} - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10" - } - } - }]) - t.equal(senderMxid, null) - t.deepEqual(promotions, []) -}) - -test("edit2changes: bot response", async t => { - const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.bot_response, data.guild.general, { - async getJoinedMembers(roomID) { - t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe") - return new Promise(resolve => { - setTimeout(() => { - resolve({ - joined: { - "@cadence:cadence.moe": { - displayname: "cadence [they]", - avatar_url: "whatever" - }, - "@_ooye_botrac4r:cadence.moe": { - displayname: "botrac4r", - avatar_url: "whatever" - } - } - }) - }) - }) - } - }) - t.deepEqual(eventsToRedact, []) - t.deepEqual(eventsToSend, []) - t.deepEqual(eventsToReplace, [{ - oldID: "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY", - newContent: { - $type: "m.room.message", - msgtype: "m.text", - body: "* :ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", - format: "org.matrix.custom.html", - formatted_body: '* :ae_botrac4r: @cadence asked ­, I respond: Stop drinking paint. (No)

Hit :bn_re: to reroll.', - "m.mentions": { - // Client-Server API spec 11.37.7: Copy Discord's behaviour by not re-notifying anyone that an *edit occurred* - }, - // *** Replaced With: *** - "m.new_content": { - msgtype: "m.text", - body: ":ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", - format: "org.matrix.custom.html", - formatted_body: ':ae_botrac4r: @cadence asked ­, I respond: Stop drinking paint. (No)

Hit :bn_re: to reroll.', - "m.mentions": { - // Client-Server API spec 11.37.7: This should contain the mentions for the final version of the event - "user_ids": ["@cadence:cadence.moe"] - } - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY" - } - } - }]) - t.equal(senderMxid, "@_ooye_bojack_horseman:cadence.moe") - t.deepEqual(promotions, []) -}) - -test("edit2changes: remove caption from image", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.removed_caption_from_image, data.guild.general, {}) - t.deepEqual(eventsToRedact, ["$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA"]) - t.deepEqual(eventsToSend, []) - t.deepEqual(eventsToReplace, []) - t.deepEqual(promotions, [{column: "part", eventID: "$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI"}]) -}) - -test("edit2changes: change file type", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.changed_file_type, data.guild.general, {}) - t.deepEqual(eventsToRedact, ["$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ"]) - t.deepEqual(eventsToSend, [{ - $type: "m.room.message", - body: "📝 Uploaded file: https://cdn.discordapp.com/attachments/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt (20 MB)", - format: "org.matrix.custom.html", - formatted_body: "📝 Uploaded file: gaze_into_my_dark_mind.txt (20 MB)", - "m.mentions": {}, - msgtype: "m.text" - }]) - t.deepEqual(eventsToReplace, []) - t.deepEqual(promotions, [{column: "part", nextEvent: true}, {column: "reaction_part", nextEvent: true}]) -}) - -test("edit2changes: add caption back to that image", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.added_caption_to_image, data.guild.general, {}) - t.deepEqual(eventsToRedact, []) - t.deepEqual(eventsToSend, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "some text", - "m.mentions": {} - }]) - t.deepEqual(eventsToReplace, []) - t.deepEqual(promotions, []) -}) - -test("edit2changes: stickers and attachments are not changed, only the content can be edited", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, {}) - t.deepEqual(eventsToRedact, []) - t.deepEqual(eventsToSend, []) - t.deepEqual(eventsToReplace, [{ - oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", - newContent: { - $type: "m.room.message", - msgtype: "m.text", - body: "* only the content can be edited", - "m.mentions": {}, - // *** Replaced With: *** - "m.new_content": { - msgtype: "m.text", - body: "only the content can be edited", - "m.mentions": {} - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4" - } - } - }]) -}) - -test("edit2changes: edit of reply to skull webp attachment with content", async t => { - const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, {}) - t.deepEqual(eventsToRedact, []) - t.deepEqual(eventsToSend, []) - t.deepEqual(eventsToReplace, [{ - oldID: "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M", - newContent: { - $type: "m.room.message", - msgtype: "m.text", - body: "> Extremity: Image\n\n* Edit", - format: "org.matrix.custom.html", - formatted_body: - '
In reply to Extremity' - + '
Image
' - + '* Edit', - "m.mentions": {}, - "m.new_content": { - msgtype: "m.text", - body: "Edit", - "m.mentions": {} - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M" - } - } - }]) -}) diff --git a/d2m/converters/message-to-event.embeds.test.js b/d2m/converters/message-to-event.embeds.test.js deleted file mode 100644 index 93d189c..0000000 --- a/d2m/converters/message-to-event.embeds.test.js +++ /dev/null @@ -1,101 +0,0 @@ -const {test} = require("supertape") -const {messageToEvent} = require("./message-to-event") -const data = require("../../test/data") -const Ty = require("../../types") - -/** - * @param {string} roomID - * @param {string} eventID - * @returns {(roomID: string, eventID: string) => Promise>} - */ -function mockGetEvent(t, roomID_in, eventID_in, outer) { - return async function(roomID, eventID) { - t.equal(roomID, roomID_in) - t.equal(eventID, eventID_in) - return new Promise(resolve => { - setTimeout(() => { - resolve({ - event_id: eventID_in, - room_id: roomID_in, - origin_server_ts: 1680000000000, - unsigned: { - age: 2245, - transaction_id: "$local.whatever" - }, - ...outer - }) - }) - }) - } -} - -test("message2event embeds: nothing but a field", async t => { - const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.notice", - body: "> **Amanda 🎵#2192 :online:" - + "\n> willow tree, branch 0**" - + "\n> **❯ Uptime:**\n> 3m 55s\n> **❯ Memory:**\n> 64.45MB", - format: "org.matrix.custom.html", - formatted_body: '
Amanda 🎵#2192 \":online:\"' - + '
willow tree, branch 0
' - + '
❯ Uptime:
3m 55s' - + '
❯ Memory:
64.45MB
' - }]) -}) - -test("message2event embeds: reply with just an embed", async t => { - const events = await messageToEvent(data.message_with_embeds.reply_with_only_embed, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.notice", - "m.mentions": {}, - body: "> [**⏺️ dynastic (@dynastic)**](https://twitter.com/i/user/719631291747078145)" - + "\n> \n> **https://twitter.com/i/status/1707484191963648161**" - + "\n> \n> does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?" - + "\n> \n> **Retweets**" - + "\n> 119" - + "\n> \n> **Likes**" - + "\n> 5581" - + "\n> \n> — Twitter", - format: "org.matrix.custom.html", - formatted_body: '
⏺️ dynastic (@dynastic)' - + '

https://twitter.com/i/status/1707484191963648161' - + '

does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?' - + '

Retweets
119

Likes
5581

— Twitter
' - }]) -}) - -test("message2event embeds: image embed and attachment", async t => { - const events = await messageToEvent(data.message_with_embeds.image_embed_and_attachment, data.guild.general, {}, { - api: { - async getJoinedMembers(roomID) { - return {joined: []} - } - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - msgtype: "m.text", - body: "https://tootsuite.net/Warp-Gate2.gif\ntanget: @ monster spawner", - format: "org.matrix.custom.html", - formatted_body: 'https://tootsuite.net/Warp-Gate2.gif
tanget: @ monster spawner', - "m.mentions": {} - }, { - $type: "m.room.message", - msgtype: "m.image", - url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR", - body: "Screenshot_20231001_034036.jpg", - external_url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&", - filename: "Screenshot_20231001_034036.jpg", - info: { - h: 1170, - w: 1080, - size: 51981, - mimetype: "image/jpeg" - }, - "m.mentions": {} - }]) -}) diff --git a/d2m/converters/message-to-event.js b/d2m/converters/message-to-event.js deleted file mode 100644 index 1a063a5..0000000 --- a/d2m/converters/message-to-event.js +++ /dev/null @@ -1,481 +0,0 @@ -// @ts-check - -const assert = require("assert").strict -const markdown = require("discord-markdown") -const pb = require("prettier-bytes") -const DiscordTypes = require("discord-api-types/v10") - -const passthrough = require("../../passthrough") -const {sync, db, discord, select, from} = passthrough -/** @type {import("../../matrix/file")} */ -const file = sync.require("../../matrix/file") -/** @type {import("./emoji-to-key")} */ -const emojiToKey = sync.require("./emoji-to-key") -/** @type {import("./lottie")} */ -const lottie = sync.require("./lottie") -const reg = require("../../matrix/read-registration") - -const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) - -/** - * @param {DiscordTypes.APIMessage} message - * @param {DiscordTypes.APIGuild} guild - * @param {boolean} useHTML - */ -function getDiscordParseCallbacks(message, guild, useHTML) { - return { - /** @param {{id: string, type: "discordUser"}} node */ - user: node => { - const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get() - const username = message.mentions.find(ment => ment.id === node.id)?.username || node.id - if (mxid && useHTML) { - return `@${username}` - } else { - return `@${username}:` - } - }, - /** @param {{id: string, type: "discordChannel"}} node */ - channel: node => { - const row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get() - if (!row) { - return `<#${node.id}>` // fallback for when this channel is not bridged - } else if (useHTML) { - return `#${row.nick || row.name}` - } else { - return `#${row.nick || row.name}` - } - }, - /** @param {{animated: boolean, name: string, id: string, type: "discordEmoji"}} node */ - emoji: node => { - if (useHTML) { - const mxc = select("emoji", "mxc_url", {emoji_id: node.id}).pluck().get() - if (mxc) { - return `:${node.name}:` - } else { // We shouldn't get here since all emojis should have been added ahead of time in the messageToEvent function. - return `:${node.name}:` - } - } else { - return `:${node.name}:` - } - }, - role: node => { - const role = guild.roles.find(r => r.id === node.id) - if (!role) { - return "@&" + node.id // fallback for if the cache breaks. if this happens, fix discord-packets.js to store the role info. - } else if (useHTML && role.color) { - return `@${role.name}` - } else if (useHTML) { - return `@${role.name}` - } else { - return `@${role.name}:` - } - }, - everyone: node => - "@room", - here: node => - "@here" - } -} - -/** - * @param {import("discord-api-types/v10").APIMessage} message - * @param {import("discord-api-types/v10").APIGuild} guild - * @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean}} options default values: - * - includeReplyFallback: true - * - includeEditFallbackStar: false - * @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API - */ -async function messageToEvent(message, guild, options = {}, di) { - const events = [] - - if (message.type === DiscordTypes.MessageType.ThreadCreated) { - // This is the kind of message that appears when somebody makes a thread which isn't close enough to the message it's based off. - // It lacks the lines and the pill, so it looks kind of like a member join message, and it says: - // [#] NICKNAME started a thread: __THREAD NAME__. __See all threads__ - // We're already bridging the THREAD_CREATED gateway event to make a comparable message, so drop this one. - return [] - } - - if (message.type === DiscordTypes.MessageType.ThreadStarterMessage) { - // This is the message that appears at the top of a thread when the thread was based off an existing message. - // It's just a message reference, no content. - const ref = message.message_reference - assert(ref) - assert(ref.message_id) - const eventID = select("event_message", "event_id", {message_id: ref.message_id}).pluck().get() - const roomID = select("channel_room", "room_id", {channel_id: ref.channel_id}).pluck().get() - if (!eventID || !roomID) return [] - const event = await di.api.getEvent(roomID, eventID) - return [{ - ...event.content, - $type: event.type, - $sender: null - }] - } - - /** - @type {{room?: boolean, user_ids?: string[]}} - We should consider the following scenarios for mentions: - 1. A discord user rich-replies to a matrix user with a text post - + The matrix user needs to be m.mentioned in the text event - + The matrix user needs to have their name/mxid/link in the text event (notification fallback) - - So prepend their `@name:` to the start of the plaintext body - 2. A discord user rich-replies to a matrix user with an image event only - + The matrix user needs to be m.mentioned in the image event - + TODO The matrix user needs to have their name/mxid in the image event's body field, alongside the filename (notification fallback) - - So append their name to the filename body, I guess!!! - 3. A discord user `@`s a matrix user in the text body of their text box - + The matrix user needs to be m.mentioned in the text event - + No change needed to the text event content: it already has their name - - So make sure we don't do anything in this case. - */ - const mentions = {} - let repliedToEventRow = null - let repliedToEventSenderMxid = null - - function addMention(mxid) { - if (!mentions.user_ids) mentions.user_ids = [] - if (!mentions.user_ids.includes(mxid)) mentions.user_ids.push(mxid) - } - - // Mentions scenarios 1 and 2, part A. i.e. translate relevant message.mentions to m.mentions - // (Still need to do scenarios 1 and 2 part B, and scenario 3.) - if (message.type === DiscordTypes.MessageType.Reply && message.message_reference?.message_id) { - const row = from("event_message").join("message_channel", "message_id").join("channel_room", "channel_id").select("event_id", "room_id", "source").and("WHERE message_id = ? AND part = 0").get(message.message_reference.message_id) - if (row) { - repliedToEventRow = row - } - } - if (repliedToEventRow && repliedToEventRow.source === 0) { // reply was originally from Matrix - // Need to figure out who sent that event... - const event = await di.api.getEvent(repliedToEventRow.room_id, repliedToEventRow.event_id) - repliedToEventSenderMxid = event.sender - // Need to add the sender to m.mentions - addMention(repliedToEventSenderMxid) - } - - async function addTextEvent(content, msgtype, {scanMentions}) { - content = content.replace(/https:\/\/(?:ptb\.|canary\.|www\.)?discord(?:app)?\.com\/channels\/([0-9]+)\/([0-9]+)\/([0-9]+)/, (whole, guildID, channelID, messageID) => { - const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get() - const roomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() - if (eventID && roomID) { - return `https://matrix.to/#/${roomID}/${eventID}` - } else { - return `${whole} [event not found]` - } - }) - - // 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?):([^:>]{2,64}):([0-9]+)>/g)] - await Promise.all(emojiMatches.map(match => { - const id = match[3] - const name = match[2] - const animated = match[1] - return emojiToKey.emojiToKey({id, name, animated}) // Register the custom emoji if needed - })) - - let html = markdown.toHTML(content, { - discordCallback: getDiscordParseCallbacks(message, guild, true) - }, null, null) - - let body = markdown.toHTML(content, { - discordCallback: getDiscordParseCallbacks(message, guild, false), - discordOnly: true, - escapeHTML: false, - }, null, null) - - // Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention. - if (scanMentions) { - const matches = [...content.matchAll(/@ ?([a-z0-9._]+)\b/gi)] - if (matches.length && matches.some(m => m[1].match(/[a-z]/i))) { - const writtenMentionsText = matches.map(m => m[1].toLowerCase()) - const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() - assert(roomID) - const {joined} = await di.api.getJoinedMembers(roomID) - for (const [mxid, member] of Object.entries(joined)) { - if (!userRegex.some(rx => mxid.match(rx))) { - const localpart = mxid.match(/@([^:]*)/) - assert(localpart) - const displayName = member.display_name || localpart[1] - if (writtenMentionsText.includes(localpart[1].toLowerCase()) || writtenMentionsText.includes(displayName.toLowerCase())) addMention(mxid) - } - } - } - } - - // Star * prefix for fallback edits - if (options.includeEditFallbackStar) { - body = "* " + body - html = "* " + html - } - - const flags = message.flags || 0 - if (flags & 2) { - body = `[🔀 ${message.author.username}]\n` + body - html = `🔀 ${message.author.username}
` + html - } - - // Fallback body/formatted_body for replies - // This branch is optional - do NOT change anything apart from the reply fallback, since it may not be run - if (repliedToEventRow && options.includeReplyFallback !== false) { - let repliedToDisplayName - let repliedToUserHtml - if (repliedToEventRow?.source === 0 && repliedToEventSenderMxid) { - const match = repliedToEventSenderMxid.match(/^@([^:]*)/) - assert(match) - repliedToDisplayName = match[1] || "a Matrix user" // grab the localpart as the display name, whatever - repliedToUserHtml = `${repliedToDisplayName}` - } else { - repliedToDisplayName = message.referenced_message?.author.global_name || message.referenced_message?.author.username || "a Discord user" - repliedToUserHtml = repliedToDisplayName - } - let repliedToContent = message.referenced_message?.content - if (repliedToContent?.startsWith("> <:L1:")) { - // If the Discord user is replying to a Matrix user's reply, the fallback is going to contain the emojis and stuff from the bridged rep of the Matrix user's reply quote. - // Need to remove that previous reply rep from this fallback body. The fallbody body should only contain the Matrix user's actual message. - repliedToContent = repliedToContent.split("\n").slice(2).join("\n") - } - 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) - }, null, null) - const repliedToBody = markdown.toHTML(repliedToContent, { - discordCallback: getDiscordParseCallbacks(message, guild, false), - discordOnly: true, - escapeHTML: false, - }, null, null) - html = `
In reply to ${repliedToUserHtml}` - + `
${repliedToHtml}
` - + html - body = (`${repliedToDisplayName}: ` // scenario 1 part B for mentions - + repliedToBody).split("\n").map(line => "> " + line).join("\n") - + "\n\n" + body - } - - const newTextMessageEvent = { - $type: "m.room.message", - "m.mentions": mentions, - msgtype, - body: body - } - - const isPlaintext = body === html - - if (!isPlaintext) { - Object.assign(newTextMessageEvent, { - format: "org.matrix.custom.html", - formatted_body: html - }) - } - - events.push(newTextMessageEvent) - } - - - let msgtype = "m.text" - // Handle message type 4, channel name changed - if (message.type === DiscordTypes.MessageType.ChannelNameChange) { - msgtype = "m.emote" - message.content = "changed the channel name to **" + message.content + "**" - } - - // Text content appears first - if (message.content) { - await addTextEvent(message.content, msgtype, {scanMentions: true}) - } - - // Then attachments - const attachmentEvents = await Promise.all(message.attachments.map(async attachment => { - const emoji = - attachment.content_type?.startsWith("image/jp") ? "📸" - : attachment.content_type?.startsWith("image/") ? "🖼️" - : attachment.content_type?.startsWith("video/") ? "🎞️" - : attachment.content_type?.startsWith("text/") ? "📝" - : attachment.content_type?.startsWith("audio/") ? "🎶" - : "📄" - // no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote - if (attachment.filename.startsWith("SPOILER_")) { - return { - $type: "m.room.message", - "m.mentions": mentions, - msgtype: "m.text", - body: `${emoji} Uploaded SPOILER file: ${attachment.url} (${pb(attachment.size)})`, - format: "org.matrix.custom.html", - formatted_body: `
${emoji} Uploaded SPOILER file: View (${pb(attachment.size)})
` - } - } - // for large files, always link them instead of uploading so I don't use up all the space in the content repo - else if (attachment.size > reg.ooye.max_file_size) { - return { - $type: "m.room.message", - "m.mentions": mentions, - msgtype: "m.text", - body: `${emoji} Uploaded file: ${attachment.url} (${pb(attachment.size)})`, - format: "org.matrix.custom.html", - formatted_body: `${emoji} Uploaded file: ${attachment.filename} (${pb(attachment.size)})` - } - } else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) { - return { - $type: "m.room.message", - "m.mentions": mentions, - msgtype: "m.image", - url: await file.uploadDiscordFileToMxc(attachment.url), - external_url: attachment.url, - body: attachment.filename, - filename: attachment.filename, - info: { - mimetype: attachment.content_type, - w: attachment.width, - h: attachment.height, - size: attachment.size - } - } - } else if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) { - return { - $type: "m.room.message", - "m.mentions": mentions, - msgtype: "m.video", - url: await file.uploadDiscordFileToMxc(attachment.url), - external_url: attachment.url, - body: attachment.description || attachment.filename, - filename: attachment.filename, - info: { - mimetype: attachment.content_type, - w: attachment.width, - h: attachment.height, - size: attachment.size - } - } - } else if (attachment.content_type?.startsWith("audio/")) { - return { - $type: "m.room.message", - "m.mentions": mentions, - msgtype: "m.audio", - url: await file.uploadDiscordFileToMxc(attachment.url), - external_url: attachment.url, - body: attachment.description || attachment.filename, - filename: attachment.filename, - info: { - mimetype: attachment.content_type, - size: attachment.size, - duration: attachment.duration_secs ? attachment.duration_secs * 1000 : undefined - } - } - } else { - return { - $type: "m.room.message", - "m.mentions": mentions, - msgtype: "m.file", - url: await file.uploadDiscordFileToMxc(attachment.url), - external_url: attachment.url, - body: attachment.filename, - filename: attachment.filename, - info: { - mimetype: attachment.content_type, - size: attachment.size - } - } - } - })) - events.push(...attachmentEvents) - - // Then embeds - for (const embed of message.embeds || []) { - if (embed.type === "image") { - continue // Matrix already does a fine enough job of providing image embeds. - } - - // Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once - let repParagraphs = [] - const makeUrlTitle = (text, url) => - ( text && url ? `[**${text}**](${url})` - : text ? `**${text}**` - : url ? `**${url}**` - : "") - - let authorNameText = embed.author?.name || "" - if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // not using the real image - let authorTitle = makeUrlTitle(authorNameText, embed.author?.url) - if (authorTitle) repParagraphs.push(authorTitle) - - let title = makeUrlTitle(embed.title, embed.url) - if (title) repParagraphs.push(title) - - if (embed.image?.url) repParagraphs.push(`📸 ${embed.image.url}`) - if (embed.video?.url) repParagraphs.push(`🎞️ ${embed.video.url}`) - - if (embed.description) repParagraphs.push(embed.description) - for (const field of embed.fields || []) { - repParagraphs.push(`**${field.name}**\n${field.value}`) - } - if (embed.footer?.text) repParagraphs.push(`— ${embed.footer.text}`) - const repContent = repParagraphs.join("\n\n") - const repContentQuoted = repContent.split("\n").map(l => "> " + l).join("\n") - - // Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person - await addTextEvent(repContentQuoted, "m.notice", {scanMentions: false}) - } - - // Then stickers - if (message.sticker_items) { - const stickerEvents = await Promise.all(message.sticker_items.map(async stickerItem => { - const format = file.stickerFormat.get(stickerItem.format_type) - if (format?.mime === "lottie") { - try { - const {mxc_url, info} = await lottie.convert(stickerItem) - return { - $type: "m.sticker", - "m.mentions": mentions, - body: stickerItem.name, - info, - url: mxc_url - } - } catch (e) { - return { - $type: "m.room.message", - "m.mentions": mentions, - msgtype: "m.notice", - body: `Failed to convert Lottie sticker:\n${e.toString()}\n${e.stack}` - } - } - } else if (format?.mime) { - let body = stickerItem.name - const sticker = guild.stickers.find(sticker => sticker.id === stickerItem.id) - if (sticker && sticker.description) body += ` - ${sticker.description}` - return { - $type: "m.sticker", - "m.mentions": mentions, - body, - info: { - mimetype: format.mime - }, - url: await file.uploadDiscordFileToMxc(file.sticker(stickerItem)) - } - } - return { - $type: "m.room.message", - "m.mentions": mentions, - msgtype: "m.notice", - body: `Unsupported sticker format ${format?.mime}. Name: ${stickerItem.name}` - } - })) - events.push(...stickerEvents) - } - - // Rich replies - if (repliedToEventRow) { - Object.assign(events[0], { - "m.relates_to": { - "m.in_reply_to": { - event_id: repliedToEventRow.event_id - } - } - }) - } - - return events -} - -module.exports.messageToEvent = messageToEvent diff --git a/d2m/converters/message-to-event.test.js b/d2m/converters/message-to-event.test.js deleted file mode 100644 index 8f89a43..0000000 --- a/d2m/converters/message-to-event.test.js +++ /dev/null @@ -1,556 +0,0 @@ -const {test} = require("supertape") -const {messageToEvent} = require("./message-to-event") -const data = require("../../test/data") -const Ty = require("../../types") - -/** - * @param {string} roomID - * @param {string} eventID - * @returns {(roomID: string, eventID: string) => Promise>} - */ -function mockGetEvent(t, roomID_in, eventID_in, outer) { - return async function(roomID, eventID) { - t.equal(roomID, roomID_in) - t.equal(eventID, eventID_in) - return new Promise(resolve => { - setTimeout(() => { - resolve({ - event_id: eventID_in, - room_id: roomID_in, - origin_server_ts: 1680000000000, - unsigned: { - age: 2245, - transaction_id: "$local.whatever" - }, - ...outer - }) - }) - }) - } -} - -test("message2event: simple plaintext", async t => { - const events = await messageToEvent(data.message.simple_plaintext, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "ayy lmao" - }]) -}) - -test("message2event: simple plaintext with quotes", async t => { - const events = await messageToEvent(data.message.simple_plaintext_with_quotes, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: `then he said, "you and her aren't allowed in here!"` - }]) -}) - -test("message2event: simple user mention", async t => { - const events = await messageToEvent(data.message.simple_user_mention, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "@crunch god: Tell me about Phil, renowned martial arts master and creator of the Chin Trick", - format: "org.matrix.custom.html", - formatted_body: '@crunch god Tell me about Phil, renowned martial arts master and creator of the Chin Trick' - }]) -}) - -test("message2event: simple room mention", async t => { - const events = await messageToEvent(data.message.simple_room_mention, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "#main", - format: "org.matrix.custom.html", - formatted_body: '#main' - }]) -}) - -test("message2event: simple role mentions", async t => { - const events = await messageToEvent(data.message.simple_role_mentions, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "I'm just @!!DLCS!!: testing a few role pings @Master Wonder Mage: don't mind me", - format: "org.matrix.custom.html", - formatted_body: `I'm just @!!DLCS!! testing a few role pings @Master Wonder Mage don't mind me` - }]) -}) - -test("message2event: simple message link", async t => { - const events = await messageToEvent(data.message.simple_message_link, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", - format: "org.matrix.custom.html", - formatted_body: 'https://matrix.to/#/!kLRqKKUQXcibIMtOpl:cadence.moe/$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg' - }]) -}) - -test("message2event: attachment with no content", async t => { - const events = await messageToEvent(data.message.attachment_no_content, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.image", - url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM", - body: "image.png", - external_url: "https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png", - filename: "image.png", - info: { - mimetype: "image/png", - w: 466, - h: 85, - size: 12919, - }, - }]) -}) - -test("message2event: spoiler attachment", async t => { - const events = await messageToEvent(data.message.spoiler_attachment, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "📄 Uploaded SPOILER file: https://cdn.discordapp.com/attachments/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)", - format: "org.matrix.custom.html", - formatted_body: "
📄 Uploaded SPOILER file: View (74 KB)
" - }]) -}) - -test("message2event: stickers", async t => { - const events = await messageToEvent(data.message.sticker, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "can have attachments too" - }, { - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.image", - url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus", - body: "image.png", - external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1106366167486038016/image.png", - filename: "image.png", - info: { - mimetype: "image/png", - w: 333, - h: 287, - size: 127373, - }, - }, { - $type: "m.sticker", - "m.mentions": {}, - body: "pomu puff - damn that tiny lil bitch really chuffing. puffing that fat ass dart", - info: { - mimetype: "image/png" - // thumbnail_url - // thumbnail_info - }, - url: "mxc://cadence.moe/UuUaLwXhkxFRwwWCXipDlBHn" - }]) -}) - -test("message2event: skull webp attachment with content", async t => { - const events = await messageToEvent(data.message.skull_webp_attachment_with_content, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "Image" - }, { - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.image", - body: "skull.webp", - info: { - w: 1200, - h: 628, - mimetype: "image/webp", - size: 74290 - }, - external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084747910918195/skull.webp", - filename: "skull.webp", - url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes" - }]) -}) - -test("message2event: reply to skull webp attachment with content", async t => { - const events = await messageToEvent(data.message.reply_to_skull_webp_attachment_with_content, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.relates_to": { - "m.in_reply_to": { - event_id: "$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q" - } - }, - "m.mentions": {}, - msgtype: "m.text", - body: "> Extremity: Image\n\nReply", - format: "org.matrix.custom.html", - formatted_body: - '
In reply to Extremity' - + '
Image
' - + 'Reply' - }, { - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.image", - body: "RDT_20230704_0936184915846675925224905.jpg", - info: { - w: 2048, - h: 1536, - mimetype: "image/jpeg", - size: 85906 - }, - external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg", - filename: "RDT_20230704_0936184915846675925224905.jpg", - url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa" - }]) -}) - -test("message2event: simple reply to matrix user", async t => { - const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, {}, { - api: { - getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", { - type: "m.room.message", - content: { - msgtype: "m.text", - body: "so can you reply to my webhook uwu" - }, - sender: "@cadence:cadence.moe" - }) - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.relates_to": { - "m.in_reply_to": { - event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4" - } - }, - "m.mentions": { - user_ids: [ - "@cadence:cadence.moe" - ] - }, - msgtype: "m.text", - body: "> cadence: so can you reply to my webhook uwu\n\nReply", - format: "org.matrix.custom.html", - formatted_body: - '
In reply to cadence' - + '
so can you reply to my webhook uwu
' - + 'Reply' - }]) -}) - -test("message2event: simple reply to matrix user, reply fallbacks disabled", async t => { - const events = await messageToEvent(data.message.simple_reply_to_matrix_user, data.guild.general, {includeReplyFallback: false}, { - api: { - getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", { - type: "m.room.message", - content: { - msgtype: "m.text", - body: "so can you reply to my webhook uwu" - }, - sender: "@cadence:cadence.moe" - }) - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.relates_to": { - "m.in_reply_to": { - event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4" - } - }, - "m.mentions": { - user_ids: [ - "@cadence:cadence.moe" - ] - }, - msgtype: "m.text", - body: "Reply" - }]) -}) - -test("message2event: simple reply in thread to a matrix user's reply", async t => { - const events = await messageToEvent(data.message.simple_reply_to_reply_in_thread, data.guild.general, {}, { - api: { - getEvent: mockGetEvent(t, "!FuDZhlOAtqswlyxzeR:cadence.moe", "$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo", { - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "> <@_ooye_cadence:cadence.moe> So what I'm wondering is about replies.\n\nWhat about them?", - format: "org.matrix.custom.html", - formatted_body: "
In reply to @_ooye_cadence:cadence.moe
So what I'm wondering is about replies.
What about them?", - "m.relates_to": { - "m.in_reply_to": { - event_id: "$fWQT8uOrzLzAXNVXz88VkGx7Oo724iS5uD8Qn5KUy9w" - } - } - }, - event_id: "$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo", - room_id: "!FuDZhlOAtqswlyxzeR:cadence.moe" - }) - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.relates_to": { - "m.in_reply_to": { - event_id: "$nUM-ABBF8KdnvrhXwLlYAE9dgDl_tskOvvcNIBrtsVo" - } - }, - "m.mentions": { - user_ids: ["@cadence:cadence.moe"] - }, - msgtype: "m.text", - body: "> cadence: What about them?\n\nWell, they don't seem to...", - format: "org.matrix.custom.html", - formatted_body: "
In reply to cadence
What about them?
Well, they don't seem to...", - }]) -}) - -test("message2event: simple written @mention for matrix user", async t => { - const events = await messageToEvent(data.message.simple_written_at_mention_for_matrix, data.guild.general, {}, { - api: { - async getJoinedMembers(roomID) { - t.equal(roomID, "!rEOspnYqdOalaIFniV:cadence.moe") - return new Promise(resolve => { - setTimeout(() => { - resolve({ - joined: { - "@she_who_brings_destruction:matrix.org": { - avatar_url: "mxc://matrix.org/FKcfnfFZlEhspeMsERfYtCuO", - display_name: "ash (Old)" - }, - "@tomskeleton:cadence.moe": { - avatar_url: "mxc://cadence.moe/OvYYicuOwfAACKaXKJCUPbVz", - display_name: "tomskeleton" - }, - "@she_who_brings_destruction:cadence.moe": { - avatar_url: "mxc://cadence.moe/XDXLMbkieETPrjFupoeiwyyq", - display_name: "ash" - }, - "@_ooye_bot:cadence.moe": { - avatar_url: "mxc://cadence.moe/jlrgFjYQHzfBvORedOmYqXVz", - display_name: "Out Of Your Element" - }, - "@cadence:cadence.moe": { - avatar_url: "mxc://cadence.moe/GJDPWiryxIhyRBNJzRNYzAlh", - display_name: "cadence [they]" - }, - "@_ooye_tomskeleton:cadence.moe": { - avatar_url: "mxc://cadence.moe/SdSrjjsrNVdyPTAKEGQUhKUK", - display_name: "tomskeleton" - }, - "@_ooye_queergasm:cadence.moe": { - avatar_url: "mxc://cadence.moe/KqXYGbUqhPPJKifLmfpoLnmB", - display_name: "queergasm" - }, - "@_ooye_.subtext:cadence.moe": { - avatar_url: "mxc://cadence.moe/heoCvaUmfCdpxdzaChwwkpEp", - display_name: ".subtext" - } - } - }) - }) - }) - } - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": { - user_ids: [ - "@she_who_brings_destruction:cadence.moe" - ] - }, - msgtype: "m.text", - body: "@ash do you need anything from the store btw as I'm heading there after gym" - }]) -}) - -test("message2event: advanced written @mentions for matrix users", async t => { - let called = 0 - const events = await messageToEvent(data.message.advanced_written_at_mention_for_matrix, data.guild.general, {}, { - api: { - async getJoinedMembers(roomID) { - called++ - t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") - return new Promise(resolve => { - setTimeout(() => { - resolve({ - joined: { - "@cadence:cadence.moe": { - display_name: "cadence [they]", - avatar_url: "whatever" - }, - "@huckleton:cadence.moe": { - display_name: "huck", - avatar_url: "whatever" - }, - "@_ooye_botrac4r:cadence.moe": { - display_name: "botrac4r", - avatar_url: "whatever" - }, - "@_ooye_bot:cadence.moe": { - display_name: "Out Of Your Element", - avatar_url: "whatever" - } - } - }) - }) - }) - } - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": { - user_ids: [ - "@cadence:cadence.moe", - "@huckleton:cadence.moe" - ] - }, - msgtype: "m.text", - body: "@Cadence, tell me about @Phil, the creator of the Chin Trick, who has become ever more powerful under the mentorship of @botrac4r and @huck" - }]) - t.equal(called, 1, "should only look up the member list once") -}) - -test("message2event: very large attachment is linked instead of being uploaded", async t => { - const events = await messageToEvent({ - content: "hey", - attachments: [{ - filename: "hey.jpg", - url: "https://discord.com/404/hey.jpg", - content_type: "application/i-made-it-up", - size: 100e6 - }] - }) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "hey" - }, { - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "📄 Uploaded file: https://discord.com/404/hey.jpg (100 MB)", - format: "org.matrix.custom.html", - formatted_body: '📄 Uploaded file: hey.jpg (100 MB)' - }]) -}) - -test("message2event: type 4 channel name change", async t => { - const events = await messageToEvent(data.special_message.thread_name_change, data.guild.general) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.emote", - body: "changed the channel name to **worming**", - format: "org.matrix.custom.html", - formatted_body: "changed the channel name to worming" - }]) -}) - -test("message2event: thread start message reference", async t => { - const events = await messageToEvent(data.special_message.thread_start_context, data.guild.general, {}, { - api: { - getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$FchUVylsOfmmbj-VwEs5Z9kY49_dt2zd0vWfylzy5Yo", { - "type": "m.room.message", - "sender": "@_ooye_kyuugryphon:cadence.moe", - "content": { - "m.mentions": {}, - "msgtype": "m.text", - "body": "layer 4" - } - }) - } - }) - t.deepEqual(events, [{ - $type: "m.room.message", - $sender: null, - msgtype: "m.text", - body: "layer 4", - "m.mentions": {} - }]) -}) - -test("message2event: single large bridged emoji", async t => { - const events = await messageToEvent(data.message.single_emoji, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: ":hippo:", - format: "org.matrix.custom.html", - formatted_body: ':hippo:' - }]) -}) - -test("message2event: mid-message small bridged emoji", async t => { - const events = await messageToEvent(data.message.surrounded_emoji, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "h is for :hippo:!", - format: "org.matrix.custom.html", - formatted_body: 'h is for :hippo:!' - }]) -}) - -test("message2event: emoji that hasn't been registered yet", async t => { - const events = await messageToEvent(data.message.not_been_registered_emoji, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: ":Yeah:", - format: "org.matrix.custom.html", - formatted_body: ':Yeah:' - }]) -}) - -test("message2event: emoji triple long name", async t => { - const events = await messageToEvent(data.message.emoji_triple_long_name, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: ":brillillillilliant_move::brillillillilliant_move::brillillillilliant_move:", - format: "org.matrix.custom.html", - formatted_body: - ':brillillillilliant_move:' - + ':brillillillilliant_move:' - + ':brillillillilliant_move:' - }]) -}) - -test("message2event: crossposted announcements say where they are crossposted from", async t => { - const events = await messageToEvent(data.special_message.crosspost_announcement, data.guild.general, {}) - t.deepEqual(events, [{ - $type: "m.room.message", - "m.mentions": {}, - msgtype: "m.text", - body: "[🔀 Chewey Bot Official Server #announcements]\nAll text based commands are now inactive on Chewey Bot\nTo continue using commands you'll need to use them as slash commands", - format: "org.matrix.custom.html", - formatted_body: "🔀 Chewey Bot Official Server #announcements
All text based commands are now inactive on Chewey Bot
To continue using commands you'll need to use them as slash commands" - }]) -}) diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js deleted file mode 100644 index f401de2..0000000 --- a/d2m/converters/pins-to-list.js +++ /dev/null @@ -1,18 +0,0 @@ -// @ts-check - -const {select} = require("../../passthrough") - -/** - * @param {import("discord-api-types/v10").RESTGetAPIChannelPinsResult} pins - */ -function pinsToList(pins) { - /** @type {string[]} */ - const result = [] - for (const message of pins) { - const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get() - if (eventID) result.push(eventID) - } - return result -} - -module.exports.pinsToList = pinsToList diff --git a/d2m/converters/pins-to-list.test.js b/d2m/converters/pins-to-list.test.js deleted file mode 100644 index c2e3774..0000000 --- a/d2m/converters/pins-to-list.test.js +++ /dev/null @@ -1,12 +0,0 @@ -const {test} = require("supertape") -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) - t.deepEqual(result, [ - "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", - "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", - "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4" - ]) -}) diff --git a/d2m/converters/rlottie-wasm.js b/d2m/converters/rlottie-wasm.js deleted file mode 100644 index 4abf45d..0000000 --- a/d2m/converters/rlottie-wasm.js +++ /dev/null @@ -1 +0,0 @@ -var Module=typeof Module!="undefined"?Module:{};var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string";var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var read_,readAsync,readBinary,setWindowTitle;if(ENVIRONMENT_IS_NODE){var fs=require("fs");var nodePath=require("path");if(ENVIRONMENT_IS_WORKER){scriptDirectory=nodePath.dirname(scriptDirectory)+"/"}else{scriptDirectory=__dirname+"/"}read_=(filename,binary)=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);return fs.readFileSync(filename,binary?undefined:"utf8")};readBinary=filename=>{var ret=read_(filename,true);if(!ret.buffer){ret=new Uint8Array(ret)}return ret};readAsync=(filename,onload,onerror,binary=true)=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);fs.readFile(filename,binary?undefined:"utf8",(err,data)=>{if(err)onerror(err);else onload(binary?data.buffer:data)})};if(!Module["thisProgram"]&&process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);if(typeof module!="undefined"){module["exports"]=Module}process.on("uncaughtException",ex=>{if(ex!=="unwind"&&!(ex instanceof ExitStatus)&&!(ex.context instanceof ExitStatus)){throw ex}});quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow};Module["inspect"]=()=>"[Emscripten Module object]"}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(scriptDirectory.indexOf("blob:")!==0){scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}else{scriptDirectory=""}{read_=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.send(null);return xhr.responseText};if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=(url,onload,onerror)=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=()=>{if(xhr.status==200||xhr.status==0&&xhr.response){onload(xhr.response);return}onerror()};xhr.onerror=onerror;xhr.send(null)}}setWindowTitle=title=>document.title=title}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["quit"])quit_=Module["quit"];var wasmBinary;if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];var noExitRuntime=Module["noExitRuntime"]||true;if(typeof WebAssembly!="object"){abort("no native wasm support detected")}var wasmMemory;var ABORT=false;var EXITSTATUS;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}var wasmTable;var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;var runtimeKeepaliveCounter=0;function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;callRuntimeCallbacks(__ATINIT__)}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}}function removeRunDependency(id){runDependencies--;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){if(Module["onAbort"]){Module["onAbort"](what)}what="Aborted("+what+")";err(what);ABORT=true;EXITSTATUS=1;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);throw e}var dataURIPrefix="data:application/octet-stream;base64,";function isDataURI(filename){return filename.startsWith(dataURIPrefix)}function isFileURI(filename){return filename.startsWith("file://")}var wasmBinaryFile;wasmBinaryFile="rlottie-wasm.wasm";if(!isDataURI(wasmBinaryFile)){wasmBinaryFile=locateFile(wasmBinaryFile)}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){if(!wasmBinary&&(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER)){if(typeof fetch=="function"&&!isFileURI(binaryFile)){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{if(!response["ok"]){throw"failed to load wasm binary file at '"+binaryFile+"'"}return response["arrayBuffer"]()}).catch(()=>getBinarySync(binaryFile))}else if(readAsync){return new Promise((resolve,reject)=>{readAsync(binaryFile,response=>resolve(new Uint8Array(response)),reject)})}}return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(instance=>instance).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&!isFileURI(binaryFile)&&!ENVIRONMENT_IS_NODE&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}return instantiateArrayBuffer(binaryFile,imports,callback)}function createWasm(){var info={"env":wasmImports,"wasi_snapshot_preview1":wasmImports};function receiveInstance(instance,module){var exports=instance.exports;wasmExports=exports;wasmMemory=wasmExports["memory"];updateMemoryViews();wasmTable=wasmExports["__indirect_function_table"];addOnInit(wasmExports["__wasm_call_ctors"]);removeRunDependency("wasm-instantiate");return exports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);return false}}instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult);return{}}var tempDouble;function ExitStatus(status){this.name="ExitStatus";this.message=`Program terminated with exit(${status})`;this.status=status}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder("utf8"):undefined;var UTF8ArrayToString=(heapOrArray,idx,maxBytesToRead)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):"";var ___assert_fail=(condition,filename,line,func)=>{abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):"unknown filename",line,func?UTF8ToString(func):"unknown function"])};function ExceptionInfo(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24;this.set_type=function(type){HEAPU32[this.ptr+4>>2]=type};this.get_type=function(){return HEAPU32[this.ptr+4>>2]};this.set_destructor=function(destructor){HEAPU32[this.ptr+8>>2]=destructor};this.get_destructor=function(){return HEAPU32[this.ptr+8>>2]};this.set_caught=function(caught){caught=caught?1:0;HEAP8[this.ptr+12>>0]=caught};this.get_caught=function(){return HEAP8[this.ptr+12>>0]!=0};this.set_rethrown=function(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13>>0]=rethrown};this.get_rethrown=function(){return HEAP8[this.ptr+13>>0]!=0};this.init=function(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)};this.set_adjusted_ptr=function(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr};this.get_adjusted_ptr=function(){return HEAPU32[this.ptr+16>>2]};this.get_exception_ptr=function(){var isPointer=___cxa_is_pointer_type(this.get_type());if(isPointer){return HEAPU32[this.excPtr>>2]}var adjusted=this.get_adjusted_ptr();if(adjusted!==0)return adjusted;return this.excPtr}}var exceptionLast=0;var uncaughtExceptionCount=0;var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast};var SYSCALLS={varargs:undefined,get(){var ret=HEAP32[SYSCALLS.varargs>>2];SYSCALLS.varargs+=4;return ret},getp(){return SYSCALLS.get()},getStr(ptr){var ret=UTF8ToString(ptr);return ret}};function ___syscall_ioctl(fd,op,varargs){SYSCALLS.varargs=varargs;return 0}function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs}var __embind_register_bigint=(primitiveType,name,size,minRange,maxRange)=>{};var embind_init_charCodes=()=>{var codes=new Array(256);for(var i=0;i<256;++i){codes[i]=String.fromCharCode(i)}embind_charCodes=codes};var embind_charCodes=undefined;var readLatin1String=ptr=>{var ret="";var c=ptr;while(HEAPU8[c]){ret+=embind_charCodes[HEAPU8[c++]]}return ret};var awaitingDependencies={};var registeredTypes={};var typeDependencies={};var BindingError=undefined;var throwBindingError=message=>{throw new BindingError(message)};var InternalError=undefined;var throwInternalError=message=>{throw new InternalError(message)};var whenDependentTypesAreResolved=(myTypes,dependentTypes,getTypeConverters)=>{myTypes.forEach(function(type){typeDependencies[type]=dependentTypes});function onComplete(typeConverters){var myTypeConverters=getTypeConverters(typeConverters);if(myTypeConverters.length!==myTypes.length){throwInternalError("Mismatched type converter count")}for(var i=0;i{if(registeredTypes.hasOwnProperty(dt)){typeConverters[i]=registeredTypes[dt]}else{unregisteredTypes.push(dt);if(!awaitingDependencies.hasOwnProperty(dt)){awaitingDependencies[dt]=[]}awaitingDependencies[dt].push(()=>{typeConverters[i]=registeredTypes[dt];++registered;if(registered===unregisteredTypes.length){onComplete(typeConverters)}})}});if(0===unregisteredTypes.length){onComplete(typeConverters)}};function sharedRegisterType(rawType,registeredInstance,options={}){var name=registeredInstance.name;if(!rawType){throwBindingError(`type "${name}" must have a positive integer typeid pointer`)}if(registeredTypes.hasOwnProperty(rawType)){if(options.ignoreDuplicateRegistrations){return}else{throwBindingError(`Cannot register type '${name}' twice`)}}registeredTypes[rawType]=registeredInstance;delete typeDependencies[rawType];if(awaitingDependencies.hasOwnProperty(rawType)){var callbacks=awaitingDependencies[rawType];delete awaitingDependencies[rawType];callbacks.forEach(cb=>cb())}}function registerType(rawType,registeredInstance,options={}){if(!("argPackAdvance"in registeredInstance)){throw new TypeError("registerType registeredInstance requires argPackAdvance")}return sharedRegisterType(rawType,registeredInstance,options)}var GenericWireTypeSize=8;var __embind_register_bool=(rawType,name,trueValue,falseValue)=>{name=readLatin1String(name);registerType(rawType,{name:name,"fromWireType":function(wt){return!!wt},"toWireType":function(destructors,o){return o?trueValue:falseValue},"argPackAdvance":GenericWireTypeSize,"readValueFromPointer":function(pointer){return this["fromWireType"](HEAPU8[pointer])},destructorFunction:null})};function ClassHandle_isAliasOf(other){if(!(this instanceof ClassHandle)){return false}if(!(other instanceof ClassHandle)){return false}var leftClass=this.$$.ptrType.registeredClass;var left=this.$$.ptr;var rightClass=other.$$.ptrType.registeredClass;var right=other.$$.ptr;while(leftClass.baseClass){left=leftClass.upcast(left);leftClass=leftClass.baseClass}while(rightClass.baseClass){right=rightClass.upcast(right);rightClass=rightClass.baseClass}return leftClass===rightClass&&left===right}var shallowCopyInternalPointer=o=>({count:o.count,deleteScheduled:o.deleteScheduled,preservePointerOnDelete:o.preservePointerOnDelete,ptr:o.ptr,ptrType:o.ptrType,smartPtr:o.smartPtr,smartPtrType:o.smartPtrType});var throwInstanceAlreadyDeleted=obj=>{function getInstanceTypeName(handle){return handle.$$.ptrType.registeredClass.name}throwBindingError(getInstanceTypeName(obj)+" instance already deleted")};var finalizationRegistry=false;var detachFinalizer=handle=>{};var runDestructor=$$=>{if($$.smartPtr){$$.smartPtrType.rawDestructor($$.smartPtr)}else{$$.ptrType.registeredClass.rawDestructor($$.ptr)}};var releaseClassHandle=$$=>{$$.count.value-=1;var toDelete=0===$$.count.value;if(toDelete){runDestructor($$)}};var downcastPointer=(ptr,ptrClass,desiredClass)=>{if(ptrClass===desiredClass){return ptr}if(undefined===desiredClass.baseClass){return null}var rv=downcastPointer(ptr,ptrClass,desiredClass.baseClass);if(rv===null){return null}return desiredClass.downcast(rv)};var registeredPointers={};var getInheritedInstanceCount=()=>Object.keys(registeredInstances).length;var getLiveInheritedInstances=()=>{var rv=[];for(var k in registeredInstances){if(registeredInstances.hasOwnProperty(k)){rv.push(registeredInstances[k])}}return rv};var deletionQueue=[];var flushPendingDeletes=()=>{while(deletionQueue.length){var obj=deletionQueue.pop();obj.$$.deleteScheduled=false;obj["delete"]()}};var delayFunction=undefined;var setDelayFunction=fn=>{delayFunction=fn;if(deletionQueue.length&&delayFunction){delayFunction(flushPendingDeletes)}};var init_embind=()=>{Module["getInheritedInstanceCount"]=getInheritedInstanceCount;Module["getLiveInheritedInstances"]=getLiveInheritedInstances;Module["flushPendingDeletes"]=flushPendingDeletes;Module["setDelayFunction"]=setDelayFunction};var registeredInstances={};var getBasestPointer=(class_,ptr)=>{if(ptr===undefined){throwBindingError("ptr should not be undefined")}while(class_.baseClass){ptr=class_.upcast(ptr);class_=class_.baseClass}return ptr};var getInheritedInstance=(class_,ptr)=>{ptr=getBasestPointer(class_,ptr);return registeredInstances[ptr]};var makeClassHandle=(prototype,record)=>{if(!record.ptrType||!record.ptr){throwInternalError("makeClassHandle requires ptr and ptrType")}var hasSmartPtrType=!!record.smartPtrType;var hasSmartPtr=!!record.smartPtr;if(hasSmartPtrType!==hasSmartPtr){throwInternalError("Both smartPtrType and smartPtr must be specified")}record.count={value:1};return attachFinalizer(Object.create(prototype,{$$:{value:record}}))};function RegisteredPointer_fromWireType(ptr){var rawPointer=this.getPointee(ptr);if(!rawPointer){this.destructor(ptr);return null}var registeredInstance=getInheritedInstance(this.registeredClass,rawPointer);if(undefined!==registeredInstance){if(0===registeredInstance.$$.count.value){registeredInstance.$$.ptr=rawPointer;registeredInstance.$$.smartPtr=ptr;return registeredInstance["clone"]()}else{var rv=registeredInstance["clone"]();this.destructor(ptr);return rv}}function makeDefaultHandle(){if(this.isSmartPointer){return makeClassHandle(this.registeredClass.instancePrototype,{ptrType:this.pointeeType,ptr:rawPointer,smartPtrType:this,smartPtr:ptr})}else{return makeClassHandle(this.registeredClass.instancePrototype,{ptrType:this,ptr:ptr})}}var actualType=this.registeredClass.getActualType(rawPointer);var registeredPointerRecord=registeredPointers[actualType];if(!registeredPointerRecord){return makeDefaultHandle.call(this)}var toType;if(this.isConst){toType=registeredPointerRecord.constPointerType}else{toType=registeredPointerRecord.pointerType}var dp=downcastPointer(rawPointer,this.registeredClass,toType.registeredClass);if(dp===null){return makeDefaultHandle.call(this)}if(this.isSmartPointer){return makeClassHandle(toType.registeredClass.instancePrototype,{ptrType:toType,ptr:dp,smartPtrType:this,smartPtr:ptr})}else{return makeClassHandle(toType.registeredClass.instancePrototype,{ptrType:toType,ptr:dp})}}var attachFinalizer=handle=>{if("undefined"===typeof FinalizationRegistry){attachFinalizer=handle=>handle;return handle}finalizationRegistry=new FinalizationRegistry(info=>{releaseClassHandle(info.$$)});attachFinalizer=handle=>{var $$=handle.$$;var hasSmartPtr=!!$$.smartPtr;if(hasSmartPtr){var info={$$:$$};finalizationRegistry.register(handle,info,handle)}return handle};detachFinalizer=handle=>finalizationRegistry.unregister(handle);return attachFinalizer(handle)};function ClassHandle_clone(){if(!this.$$.ptr){throwInstanceAlreadyDeleted(this)}if(this.$$.preservePointerOnDelete){this.$$.count.value+=1;return this}else{var clone=attachFinalizer(Object.create(Object.getPrototypeOf(this),{$$:{value:shallowCopyInternalPointer(this.$$)}}));clone.$$.count.value+=1;clone.$$.deleteScheduled=false;return clone}}function ClassHandle_delete(){if(!this.$$.ptr){throwInstanceAlreadyDeleted(this)}if(this.$$.deleteScheduled&&!this.$$.preservePointerOnDelete){throwBindingError("Object already scheduled for deletion")}detachFinalizer(this);releaseClassHandle(this.$$);if(!this.$$.preservePointerOnDelete){this.$$.smartPtr=undefined;this.$$.ptr=undefined}}function ClassHandle_isDeleted(){return!this.$$.ptr}function ClassHandle_deleteLater(){if(!this.$$.ptr){throwInstanceAlreadyDeleted(this)}if(this.$$.deleteScheduled&&!this.$$.preservePointerOnDelete){throwBindingError("Object already scheduled for deletion")}deletionQueue.push(this);if(deletionQueue.length===1&&delayFunction){delayFunction(flushPendingDeletes)}this.$$.deleteScheduled=true;return this}var init_ClassHandle=()=>{ClassHandle.prototype["isAliasOf"]=ClassHandle_isAliasOf;ClassHandle.prototype["clone"]=ClassHandle_clone;ClassHandle.prototype["delete"]=ClassHandle_delete;ClassHandle.prototype["isDeleted"]=ClassHandle_isDeleted;ClassHandle.prototype["deleteLater"]=ClassHandle_deleteLater};function ClassHandle(){}var char_0=48;var char_9=57;var makeLegalFunctionName=name=>{if(undefined===name){return"_unknown"}name=name.replace(/[^a-zA-Z0-9_]/g,"$");var f=name.charCodeAt(0);if(f>=char_0&&f<=char_9){return`_${name}`}return name};function createNamedFunction(name,body){name=makeLegalFunctionName(name);return{[name]:function(){return body.apply(this,arguments)}}[name]}var ensureOverloadTable=(proto,methodName,humanName)=>{if(undefined===proto[methodName].overloadTable){var prevFunc=proto[methodName];proto[methodName]=function(){if(!proto[methodName].overloadTable.hasOwnProperty(arguments.length)){throwBindingError(`Function '${humanName}' called with an invalid number of arguments (${arguments.length}) - expects one of (${proto[methodName].overloadTable})!`)}return proto[methodName].overloadTable[arguments.length].apply(this,arguments)};proto[methodName].overloadTable=[];proto[methodName].overloadTable[prevFunc.argCount]=prevFunc}};var exposePublicSymbol=(name,value,numArguments)=>{if(Module.hasOwnProperty(name)){if(undefined===numArguments||undefined!==Module[name].overloadTable&&undefined!==Module[name].overloadTable[numArguments]){throwBindingError(`Cannot register public name '${name}' twice`)}ensureOverloadTable(Module,name,name);if(Module.hasOwnProperty(numArguments)){throwBindingError(`Cannot register multiple overloads of a function with the same number of arguments (${numArguments})!`)}Module[name].overloadTable[numArguments]=value}else{Module[name]=value;if(undefined!==numArguments){Module[name].numArguments=numArguments}}};function RegisteredClass(name,constructor,instancePrototype,rawDestructor,baseClass,getActualType,upcast,downcast){this.name=name;this.constructor=constructor;this.instancePrototype=instancePrototype;this.rawDestructor=rawDestructor;this.baseClass=baseClass;this.getActualType=getActualType;this.upcast=upcast;this.downcast=downcast;this.pureVirtualFunctions=[]}var upcastPointer=(ptr,ptrClass,desiredClass)=>{while(ptrClass!==desiredClass){if(!ptrClass.upcast){throwBindingError(`Expected null or instance of ${desiredClass.name}, got an instance of ${ptrClass.name}`)}ptr=ptrClass.upcast(ptr);ptrClass=ptrClass.baseClass}return ptr};function constNoSmartPtrRawPointerToWireType(destructors,handle){if(handle===null){if(this.isReference){throwBindingError(`null is not a valid ${this.name}`)}return 0}if(!handle.$$){throwBindingError(`Cannot pass "${embindRepr(handle)}" as a ${this.name}`)}if(!handle.$$.ptr){throwBindingError(`Cannot pass deleted object as a pointer of type ${this.name}`)}var handleClass=handle.$$.ptrType.registeredClass;var ptr=upcastPointer(handle.$$.ptr,handleClass,this.registeredClass);return ptr}function genericPointerToWireType(destructors,handle){var ptr;if(handle===null){if(this.isReference){throwBindingError(`null is not a valid ${this.name}`)}if(this.isSmartPointer){ptr=this.rawConstructor();if(destructors!==null){destructors.push(this.rawDestructor,ptr)}return ptr}else{return 0}}if(!handle.$$){throwBindingError(`Cannot pass "${embindRepr(handle)}" as a ${this.name}`)}if(!handle.$$.ptr){throwBindingError(`Cannot pass deleted object as a pointer of type ${this.name}`)}if(!this.isConst&&handle.$$.ptrType.isConst){throwBindingError(`Cannot convert argument of type ${handle.$$.smartPtrType?handle.$$.smartPtrType.name:handle.$$.ptrType.name} to parameter type ${this.name}`)}var handleClass=handle.$$.ptrType.registeredClass;ptr=upcastPointer(handle.$$.ptr,handleClass,this.registeredClass);if(this.isSmartPointer){if(undefined===handle.$$.smartPtr){throwBindingError("Passing raw pointer to smart pointer is illegal")}switch(this.sharingPolicy){case 0:if(handle.$$.smartPtrType===this){ptr=handle.$$.smartPtr}else{throwBindingError(`Cannot convert argument of type ${handle.$$.smartPtrType?handle.$$.smartPtrType.name:handle.$$.ptrType.name} to parameter type ${this.name}`)}break;case 1:ptr=handle.$$.smartPtr;break;case 2:if(handle.$$.smartPtrType===this){ptr=handle.$$.smartPtr}else{var clonedHandle=handle["clone"]();ptr=this.rawShare(ptr,Emval.toHandle(()=>clonedHandle["delete"]()));if(destructors!==null){destructors.push(this.rawDestructor,ptr)}}break;default:throwBindingError("Unsupporting sharing policy")}}return ptr}function nonConstNoSmartPtrRawPointerToWireType(destructors,handle){if(handle===null){if(this.isReference){throwBindingError(`null is not a valid ${this.name}`)}return 0}if(!handle.$$){throwBindingError(`Cannot pass "${embindRepr(handle)}" as a ${this.name}`)}if(!handle.$$.ptr){throwBindingError(`Cannot pass deleted object as a pointer of type ${this.name}`)}if(handle.$$.ptrType.isConst){throwBindingError(`Cannot convert argument of type ${handle.$$.ptrType.name} to parameter type ${this.name}`)}var handleClass=handle.$$.ptrType.registeredClass;var ptr=upcastPointer(handle.$$.ptr,handleClass,this.registeredClass);return ptr}function readPointer(pointer){return this["fromWireType"](HEAPU32[pointer>>2])}function RegisteredPointer_getPointee(ptr){if(this.rawGetPointee){ptr=this.rawGetPointee(ptr)}return ptr}function RegisteredPointer_destructor(ptr){if(this.rawDestructor){this.rawDestructor(ptr)}}var RegisteredPointer_deleteObject=handle=>{if(handle!==null){handle["delete"]()}};var init_RegisteredPointer=()=>{RegisteredPointer.prototype.getPointee=RegisteredPointer_getPointee;RegisteredPointer.prototype.destructor=RegisteredPointer_destructor;RegisteredPointer.prototype["argPackAdvance"]=GenericWireTypeSize;RegisteredPointer.prototype["readValueFromPointer"]=readPointer;RegisteredPointer.prototype["deleteObject"]=RegisteredPointer_deleteObject;RegisteredPointer.prototype["fromWireType"]=RegisteredPointer_fromWireType};function RegisteredPointer(name,registeredClass,isReference,isConst,isSmartPointer,pointeeType,sharingPolicy,rawGetPointee,rawConstructor,rawShare,rawDestructor){this.name=name;this.registeredClass=registeredClass;this.isReference=isReference;this.isConst=isConst;this.isSmartPointer=isSmartPointer;this.pointeeType=pointeeType;this.sharingPolicy=sharingPolicy;this.rawGetPointee=rawGetPointee;this.rawConstructor=rawConstructor;this.rawShare=rawShare;this.rawDestructor=rawDestructor;if(!isSmartPointer&®isteredClass.baseClass===undefined){if(isConst){this["toWireType"]=constNoSmartPtrRawPointerToWireType;this.destructorFunction=null}else{this["toWireType"]=nonConstNoSmartPtrRawPointerToWireType;this.destructorFunction=null}}else{this["toWireType"]=genericPointerToWireType}}var replacePublicSymbol=(name,value,numArguments)=>{if(!Module.hasOwnProperty(name)){throwInternalError("Replacing nonexistant public symbol")}if(undefined!==Module[name].overloadTable&&undefined!==numArguments){Module[name].overloadTable[numArguments]=value}else{Module[name]=value;Module[name].argCount=numArguments}};var dynCallLegacy=(sig,ptr,args)=>{var f=Module["dynCall_"+sig];return args&&args.length?f.apply(null,[ptr].concat(args)):f.call(null,ptr)};var wasmTableMirror=[];var getWasmTableEntry=funcPtr=>{var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}return func};var dynCall=(sig,ptr,args)=>{if(sig.includes("j")){return dynCallLegacy(sig,ptr,args)}var rtn=getWasmTableEntry(ptr).apply(null,args);return rtn};var getDynCaller=(sig,ptr)=>{var argCache=[];return function(){argCache.length=0;Object.assign(argCache,arguments);return dynCall(sig,ptr,argCache)}};var embind__requireFunction=(signature,rawFunction)=>{signature=readLatin1String(signature);function makeDynCaller(){if(signature.includes("j")){return getDynCaller(signature,rawFunction)}return getWasmTableEntry(rawFunction)}var fp=makeDynCaller();if(typeof fp!="function"){throwBindingError(`unknown function pointer with signature ${signature}: ${rawFunction}`)}return fp};var extendError=(baseErrorType,errorName)=>{var errorClass=createNamedFunction(errorName,function(message){this.name=errorName;this.message=message;var stack=new Error(message).stack;if(stack!==undefined){this.stack=this.toString()+"\n"+stack.replace(/^Error(:[^\n]*)?\n/,"")}});errorClass.prototype=Object.create(baseErrorType.prototype);errorClass.prototype.constructor=errorClass;errorClass.prototype.toString=function(){if(this.message===undefined){return this.name}else{return`${this.name}: ${this.message}`}};return errorClass};var UnboundTypeError=undefined;var getTypeName=type=>{var ptr=___getTypeName(type);var rv=readLatin1String(ptr);_free(ptr);return rv};var throwUnboundTypeError=(message,types)=>{var unboundTypes=[];var seen={};function visit(type){if(seen[type]){return}if(registeredTypes[type]){return}if(typeDependencies[type]){typeDependencies[type].forEach(visit);return}unboundTypes.push(type);seen[type]=true}types.forEach(visit);throw new UnboundTypeError(`${message}: `+unboundTypes.map(getTypeName).join([", "]))};var __embind_register_class=(rawType,rawPointerType,rawConstPointerType,baseClassRawType,getActualTypeSignature,getActualType,upcastSignature,upcast,downcastSignature,downcast,name,destructorSignature,rawDestructor)=>{name=readLatin1String(name);getActualType=embind__requireFunction(getActualTypeSignature,getActualType);if(upcast){upcast=embind__requireFunction(upcastSignature,upcast)}if(downcast){downcast=embind__requireFunction(downcastSignature,downcast)}rawDestructor=embind__requireFunction(destructorSignature,rawDestructor);var legalFunctionName=makeLegalFunctionName(name);exposePublicSymbol(legalFunctionName,function(){throwUnboundTypeError(`Cannot construct ${name} due to unbound types`,[baseClassRawType])});whenDependentTypesAreResolved([rawType,rawPointerType,rawConstPointerType],baseClassRawType?[baseClassRawType]:[],function(base){base=base[0];var baseClass;var basePrototype;if(baseClassRawType){baseClass=base.registeredClass;basePrototype=baseClass.instancePrototype}else{basePrototype=ClassHandle.prototype}var constructor=createNamedFunction(legalFunctionName,function(){if(Object.getPrototypeOf(this)!==instancePrototype){throw new BindingError("Use 'new' to construct "+name)}if(undefined===registeredClass.constructor_body){throw new BindingError(name+" has no accessible constructor")}var body=registeredClass.constructor_body[arguments.length];if(undefined===body){throw new BindingError(`Tried to invoke ctor of ${name} with invalid number of parameters (${arguments.length}) - expected (${Object.keys(registeredClass.constructor_body).toString()}) parameters instead!`)}return body.apply(this,arguments)});var instancePrototype=Object.create(basePrototype,{constructor:{value:constructor}});constructor.prototype=instancePrototype;var registeredClass=new RegisteredClass(name,constructor,instancePrototype,rawDestructor,baseClass,getActualType,upcast,downcast);if(registeredClass.baseClass){if(registeredClass.baseClass.__derivedClasses===undefined){registeredClass.baseClass.__derivedClasses=[]}registeredClass.baseClass.__derivedClasses.push(registeredClass)}var referenceConverter=new RegisteredPointer(name,registeredClass,true,false,false);var pointerConverter=new RegisteredPointer(name+"*",registeredClass,false,false,false);var constPointerConverter=new RegisteredPointer(name+" const*",registeredClass,false,true,false);registeredPointers[rawType]={pointerType:pointerConverter,constPointerType:constPointerConverter};replacePublicSymbol(legalFunctionName,constructor);return[referenceConverter,pointerConverter,constPointerConverter]})};var heap32VectorToArray=(count,firstElement)=>{var array=[];for(var i=0;i>2])}return array};var runDestructors=destructors=>{while(destructors.length){var ptr=destructors.pop();var del=destructors.pop();del(ptr)}};function newFunc(constructor,argumentList){if(!(constructor instanceof Function)){throw new TypeError(`new_ called with constructor type ${typeof constructor} which is not a function`)}var dummy=createNamedFunction(constructor.name||"unknownFunctionName",function(){});dummy.prototype=constructor.prototype;var obj=new dummy;var r=constructor.apply(obj,argumentList);return r instanceof Object?r:obj}function craftInvokerFunction(humanName,argTypes,classType,cppInvokerFunc,cppTargetFunc,isAsync){var argCount=argTypes.length;if(argCount<2){throwBindingError("argTypes array size mismatch! Must at least get return value and 'this' types!")}var isClassMethodFunc=argTypes[1]!==null&&classType!==null;var needsDestructorStack=false;for(var i=1;i0?", ":"")+argsListWired}invokerFnBody+=(returns||isAsync?"var rv = ":"")+"invoker(fn"+(argsListWired.length>0?", ":"")+argsListWired+");\n";if(needsDestructorStack){invokerFnBody+="runDestructors(destructors);\n"}else{for(var i=isClassMethodFunc?1:2;i{var rawArgTypes=heap32VectorToArray(argCount,rawArgTypesAddr);invoker=embind__requireFunction(invokerSignature,invoker);whenDependentTypesAreResolved([],[rawClassType],function(classType){classType=classType[0];var humanName=`constructor ${classType.name}`;if(undefined===classType.registeredClass.constructor_body){classType.registeredClass.constructor_body=[]}if(undefined!==classType.registeredClass.constructor_body[argCount-1]){throw new BindingError(`Cannot register multiple constructors with identical number of parameters (${argCount-1}) for class '${classType.name}'! Overload resolution is currently only performed using the parameter count, not actual type info!`)}classType.registeredClass.constructor_body[argCount-1]=()=>{throwUnboundTypeError(`Cannot construct ${classType.name} due to unbound types`,rawArgTypes)};whenDependentTypesAreResolved([],rawArgTypes,argTypes=>{argTypes.splice(1,0,null);classType.registeredClass.constructor_body[argCount-1]=craftInvokerFunction(humanName,argTypes,null,invoker,rawConstructor);return[]});return[]})};var __embind_register_class_function=(rawClassType,methodName,argCount,rawArgTypesAddr,invokerSignature,rawInvoker,context,isPureVirtual,isAsync)=>{var rawArgTypes=heap32VectorToArray(argCount,rawArgTypesAddr);methodName=readLatin1String(methodName);rawInvoker=embind__requireFunction(invokerSignature,rawInvoker);whenDependentTypesAreResolved([],[rawClassType],function(classType){classType=classType[0];var humanName=`${classType.name}.${methodName}`;if(methodName.startsWith("@@")){methodName=Symbol[methodName.substring(2)]}if(isPureVirtual){classType.registeredClass.pureVirtualFunctions.push(methodName)}function unboundTypesHandler(){throwUnboundTypeError(`Cannot call ${humanName} due to unbound types`,rawArgTypes)}var proto=classType.registeredClass.instancePrototype;var method=proto[methodName];if(undefined===method||undefined===method.overloadTable&&method.className!==classType.name&&method.argCount===argCount-2){unboundTypesHandler.argCount=argCount-2;unboundTypesHandler.className=classType.name;proto[methodName]=unboundTypesHandler}else{ensureOverloadTable(proto,methodName,humanName);proto[methodName].overloadTable[argCount-2]=unboundTypesHandler}whenDependentTypesAreResolved([],rawArgTypes,function(argTypes){var memberFunction=craftInvokerFunction(humanName,argTypes,classType,rawInvoker,context,isAsync);if(undefined===proto[methodName].overloadTable){memberFunction.argCount=argCount-2;proto[methodName]=memberFunction}else{proto[methodName].overloadTable[argCount-2]=memberFunction}return[]});return[]})};function handleAllocatorInit(){Object.assign(HandleAllocator.prototype,{get(id){return this.allocated[id]},has(id){return this.allocated[id]!==undefined},allocate(handle){var id=this.freelist.pop()||this.allocated.length;this.allocated[id]=handle;return id},free(id){this.allocated[id]=undefined;this.freelist.push(id)}})}function HandleAllocator(){this.allocated=[undefined];this.freelist=[]}var emval_handles=new HandleAllocator;var __emval_decref=handle=>{if(handle>=emval_handles.reserved&&0===--emval_handles.get(handle).refcount){emval_handles.free(handle)}};var count_emval_handles=()=>{var count=0;for(var i=emval_handles.reserved;i{emval_handles.allocated.push({value:undefined},{value:null},{value:true},{value:false});emval_handles.reserved=emval_handles.allocated.length;Module["count_emval_handles"]=count_emval_handles};var Emval={toValue:handle=>{if(!handle){throwBindingError("Cannot use deleted val. handle = "+handle)}return emval_handles.get(handle).value},toHandle:value=>{switch(value){case undefined:return 1;case null:return 2;case true:return 3;case false:return 4;default:{return emval_handles.allocate({refcount:1,value:value})}}}};function simpleReadValueFromPointer(pointer){return this["fromWireType"](HEAP32[pointer>>2])}var __embind_register_emval=(rawType,name)=>{name=readLatin1String(name);registerType(rawType,{name:name,"fromWireType":handle=>{var rv=Emval.toValue(handle);__emval_decref(handle);return rv},"toWireType":(destructors,value)=>Emval.toHandle(value),"argPackAdvance":GenericWireTypeSize,"readValueFromPointer":simpleReadValueFromPointer,destructorFunction:null})};var embindRepr=v=>{if(v===null){return"null"}var t=typeof v;if(t==="object"||t==="array"||t==="function"){return v.toString()}else{return""+v}};var floatReadValueFromPointer=(name,width)=>{switch(width){case 4:return function(pointer){return this["fromWireType"](HEAPF32[pointer>>2])};case 8:return function(pointer){return this["fromWireType"](HEAPF64[pointer>>3])};default:throw new TypeError(`invalid float width (${width}): ${name}`)}};var __embind_register_float=(rawType,name,size)=>{name=readLatin1String(name);registerType(rawType,{name:name,"fromWireType":value=>value,"toWireType":(destructors,value)=>value,"argPackAdvance":GenericWireTypeSize,"readValueFromPointer":floatReadValueFromPointer(name,size),destructorFunction:null})};var integerReadValueFromPointer=(name,width,signed)=>{switch(width){case 1:return signed?pointer=>HEAP8[pointer>>0]:pointer=>HEAPU8[pointer>>0];case 2:return signed?pointer=>HEAP16[pointer>>1]:pointer=>HEAPU16[pointer>>1];case 4:return signed?pointer=>HEAP32[pointer>>2]:pointer=>HEAPU32[pointer>>2];default:throw new TypeError(`invalid integer width (${width}): ${name}`)}};var __embind_register_integer=(primitiveType,name,size,minRange,maxRange)=>{name=readLatin1String(name);if(maxRange===-1){maxRange=4294967295}var fromWireType=value=>value;if(minRange===0){var bitshift=32-8*size;fromWireType=value=>value<>>bitshift}var isUnsignedType=name.includes("unsigned");var checkAssertions=(value,toTypeName)=>{};var toWireType;if(isUnsignedType){toWireType=function(destructors,value){checkAssertions(value,this.name);return value>>>0}}else{toWireType=function(destructors,value){checkAssertions(value,this.name);return value}}registerType(primitiveType,{name:name,"fromWireType":fromWireType,"toWireType":toWireType,"argPackAdvance":GenericWireTypeSize,"readValueFromPointer":integerReadValueFromPointer(name,size,minRange!==0),destructorFunction:null})};var __embind_register_memory_view=(rawType,dataTypeIndex,name)=>{var typeMapping=[Int8Array,Uint8Array,Int16Array,Uint16Array,Int32Array,Uint32Array,Float32Array,Float64Array];var TA=typeMapping[dataTypeIndex];function decodeMemoryView(handle){var size=HEAPU32[handle>>2];var data=HEAPU32[handle+4>>2];return new TA(HEAP8.buffer,data,size)}name=readLatin1String(name);registerType(rawType,{name:name,"fromWireType":decodeMemoryView,"argPackAdvance":GenericWireTypeSize,"readValueFromPointer":decodeMemoryView},{ignoreDuplicateRegistrations:true})};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var __embind_register_std_string=(rawType,name)=>{name=readLatin1String(name);var stdStringIsUTF8=name==="std::string";registerType(rawType,{name:name,"fromWireType":value=>{var length=HEAPU32[value>>2];var payload=value+4;var str;if(stdStringIsUTF8){var decodeStartPtr=payload;for(var i=0;i<=length;++i){var currentBytePtr=payload+i;if(i==length||HEAPU8[currentBytePtr]==0){var maxRead=currentBytePtr-decodeStartPtr;var stringSegment=UTF8ToString(decodeStartPtr,maxRead);if(str===undefined){str=stringSegment}else{str+=String.fromCharCode(0);str+=stringSegment}decodeStartPtr=currentBytePtr+1}}}else{var a=new Array(length);for(var i=0;i{if(value instanceof ArrayBuffer){value=new Uint8Array(value)}var length;var valueIsOfTypeString=typeof value=="string";if(!(valueIsOfTypeString||value instanceof Uint8Array||value instanceof Uint8ClampedArray||value instanceof Int8Array)){throwBindingError("Cannot pass non-string to std::string")}if(stdStringIsUTF8&&valueIsOfTypeString){length=lengthBytesUTF8(value)}else{length=value.length}var base=_malloc(4+length+1);var ptr=base+4;HEAPU32[base>>2]=length;if(stdStringIsUTF8&&valueIsOfTypeString){stringToUTF8(value,ptr,length+1)}else{if(valueIsOfTypeString){for(var i=0;i255){_free(ptr);throwBindingError("String has UTF-16 code units that do not fit in 8 bits")}HEAPU8[ptr+i]=charCode}}else{for(var i=0;i_free(ptr)})};var UTF16Decoder=typeof TextDecoder!="undefined"?new TextDecoder("utf-16le"):undefined;var UTF16ToString=(ptr,maxBytesToRead)=>{var endPtr=ptr;var idx=endPtr>>1;var maxIdx=idx+maxBytesToRead/2;while(!(idx>=maxIdx)&&HEAPU16[idx])++idx;endPtr=idx<<1;if(endPtr-ptr>32&&UTF16Decoder)return UTF16Decoder.decode(HEAPU8.subarray(ptr,endPtr));var str="";for(var i=0;!(i>=maxBytesToRead/2);++i){var codeUnit=HEAP16[ptr+i*2>>1];if(codeUnit==0)break;str+=String.fromCharCode(codeUnit)}return str};var stringToUTF16=(str,outPtr,maxBytesToWrite)=>{if(maxBytesToWrite===undefined){maxBytesToWrite=2147483647}if(maxBytesToWrite<2)return 0;maxBytesToWrite-=2;var startPtr=outPtr;var numCharsToWrite=maxBytesToWrite>1]=codeUnit;outPtr+=2}HEAP16[outPtr>>1]=0;return outPtr-startPtr};var lengthBytesUTF16=str=>str.length*2;var UTF32ToString=(ptr,maxBytesToRead)=>{var i=0;var str="";while(!(i>=maxBytesToRead/4)){var utf32=HEAP32[ptr+i*4>>2];if(utf32==0)break;++i;if(utf32>=65536){var ch=utf32-65536;str+=String.fromCharCode(55296|ch>>10,56320|ch&1023)}else{str+=String.fromCharCode(utf32)}}return str};var stringToUTF32=(str,outPtr,maxBytesToWrite)=>{if(maxBytesToWrite===undefined){maxBytesToWrite=2147483647}if(maxBytesToWrite<4)return 0;var startPtr=outPtr;var endPtr=startPtr+maxBytesToWrite-4;for(var i=0;i=55296&&codeUnit<=57343){var trailSurrogate=str.charCodeAt(++i);codeUnit=65536+((codeUnit&1023)<<10)|trailSurrogate&1023}HEAP32[outPtr>>2]=codeUnit;outPtr+=4;if(outPtr+4>endPtr)break}HEAP32[outPtr>>2]=0;return outPtr-startPtr};var lengthBytesUTF32=str=>{var len=0;for(var i=0;i=55296&&codeUnit<=57343)++i;len+=4}return len};var __embind_register_std_wstring=(rawType,charSize,name)=>{name=readLatin1String(name);var decodeString,encodeString,getHeap,lengthBytesUTF,shift;if(charSize===2){decodeString=UTF16ToString;encodeString=stringToUTF16;lengthBytesUTF=lengthBytesUTF16;getHeap=()=>HEAPU16;shift=1}else if(charSize===4){decodeString=UTF32ToString;encodeString=stringToUTF32;lengthBytesUTF=lengthBytesUTF32;getHeap=()=>HEAPU32;shift=2}registerType(rawType,{name:name,"fromWireType":value=>{var length=HEAPU32[value>>2];var HEAP=getHeap();var str;var decodeStartPtr=value+4;for(var i=0;i<=length;++i){var currentBytePtr=value+4+i*charSize;if(i==length||HEAP[currentBytePtr>>shift]==0){var maxReadBytes=currentBytePtr-decodeStartPtr;var stringSegment=decodeString(decodeStartPtr,maxReadBytes);if(str===undefined){str=stringSegment}else{str+=String.fromCharCode(0);str+=stringSegment}decodeStartPtr=currentBytePtr+charSize}}_free(value);return str},"toWireType":(destructors,value)=>{if(!(typeof value=="string")){throwBindingError(`Cannot pass non-string to C++ string type ${name}`)}var length=lengthBytesUTF(value);var ptr=_malloc(4+length+charSize);HEAPU32[ptr>>2]=length>>shift;encodeString(value,ptr+4,length+charSize);if(destructors!==null){destructors.push(_free,ptr)}return ptr},"argPackAdvance":GenericWireTypeSize,"readValueFromPointer":simpleReadValueFromPointer,destructorFunction:ptr=>_free(ptr)})};var __embind_register_void=(rawType,name)=>{name=readLatin1String(name);registerType(rawType,{isVoid:true,name:name,"argPackAdvance":0,"fromWireType":()=>undefined,"toWireType":(destructors,o)=>undefined})};var __emscripten_throw_longjmp=()=>{throw Infinity};var __emval_incref=handle=>{if(handle>4){emval_handles.get(handle).refcount+=1}};var requireRegisteredType=(rawType,humanName)=>{var impl=registeredTypes[rawType];if(undefined===impl){throwBindingError(humanName+" has unknown type "+getTypeName(rawType))}return impl};var __emval_take_value=(type,arg)=>{type=requireRegisteredType(type,"_emval_take_value");var v=type["readValueFromPointer"](arg);return Emval.toHandle(v)};var isLeapYear=year=>year%4===0&&(year%100!==0||year%400===0);var MONTH_DAYS_LEAP_CUMULATIVE=[0,31,60,91,121,152,182,213,244,274,305,335];var MONTH_DAYS_REGULAR_CUMULATIVE=[0,31,59,90,120,151,181,212,243,273,304,334];var ydayFromDate=date=>{var leap=isLeapYear(date.getFullYear());var monthDaysCumulative=leap?MONTH_DAYS_LEAP_CUMULATIVE:MONTH_DAYS_REGULAR_CUMULATIVE;var yday=monthDaysCumulative[date.getMonth()]+date.getDate()-1;return yday};var convertI32PairToI53Checked=(lo,hi)=>hi+2097152>>>0<4194305-!!lo?(lo>>>0)+hi*4294967296:NaN;function __localtime_js(time_low,time_high,tmPtr){var time=convertI32PairToI53Checked(time_low,time_high);var date=new Date(time*1e3);HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr+36>>2]=-(date.getTimezoneOffset()*60);var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dst=(summerOffset!=winterOffset&&date.getTimezoneOffset()==Math.min(winterOffset,summerOffset))|0;HEAP32[tmPtr+32>>2]=dst}var __mktime_js=function(tmPtr){var ret=(()=>{var date=new Date(HEAP32[tmPtr+20>>2]+1900,HEAP32[tmPtr+16>>2],HEAP32[tmPtr+12>>2],HEAP32[tmPtr+8>>2],HEAP32[tmPtr+4>>2],HEAP32[tmPtr>>2],0);var dst=HEAP32[tmPtr+32>>2];var guessedOffset=date.getTimezoneOffset();var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dstOffset=Math.min(winterOffset,summerOffset);if(dst<0){HEAP32[tmPtr+32>>2]=Number(summerOffset!=winterOffset&&dstOffset==guessedOffset)}else if(dst>0!=(dstOffset==guessedOffset)){var nonDstOffset=Math.max(winterOffset,summerOffset);var trueOffset=dst>0?dstOffset:nonDstOffset;date.setTime(date.getTime()+(trueOffset-guessedOffset)*6e4)}HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getYear();return date.getTime()/1e3})();return setTempRet0((tempDouble=ret,+Math.abs(tempDouble)>=1?tempDouble>0?+Math.floor(tempDouble/4294967296)>>>0:~~+Math.ceil((tempDouble-+(~~tempDouble>>>0))/4294967296)>>>0:0)),ret>>>0};var stringToNewUTF8=str=>{var size=lengthBytesUTF8(str)+1;var ret=_malloc(size);if(ret)stringToUTF8(str,ret,size);return ret};var __tzset_js=(timezone,daylight,tzname)=>{var currentYear=(new Date).getFullYear();var winter=new Date(currentYear,0,1);var summer=new Date(currentYear,6,1);var winterOffset=winter.getTimezoneOffset();var summerOffset=summer.getTimezoneOffset();var stdTimezoneOffset=Math.max(winterOffset,summerOffset);HEAPU32[timezone>>2]=stdTimezoneOffset*60;HEAP32[daylight>>2]=Number(winterOffset!=summerOffset);function extractZone(date){var match=date.toTimeString().match(/\(([A-Za-z ]+)\)$/);return match?match[1]:"GMT"}var winterName=extractZone(winter);var summerName=extractZone(summer);var winterNamePtr=stringToNewUTF8(winterName);var summerNamePtr=stringToNewUTF8(summerName);if(summerOffset>2]=winterNamePtr;HEAPU32[tzname+4>>2]=summerNamePtr}else{HEAPU32[tzname>>2]=summerNamePtr;HEAPU32[tzname+4>>2]=winterNamePtr}};var _abort=()=>{abort("")};var _emscripten_memcpy_big=(dest,src,num)=>HEAPU8.copyWithin(dest,src,src+num);var getHeapMax=()=>2147483648;var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}var alignUp=(x,multiple)=>x+(multiple-x%multiple)%multiple;for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignUp(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var ENV={};var getExecutableName=()=>thisProgram||"./this.program";var getEnvStrings=()=>{if(!getEnvStrings.strings){var lang=(typeof navigator=="object"&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8";var env={"USER":"web_user","LOGNAME":"web_user","PATH":"/","PWD":"/","HOME":"/home/web_user","LANG":lang,"_":getExecutableName()};for(var x in ENV){if(ENV[x]===undefined)delete env[x];else env[x]=ENV[x]}var strings=[];for(var x in env){strings.push(`${x}=${env[x]}`)}getEnvStrings.strings=strings}return getEnvStrings.strings};var stringToAscii=(str,buffer)=>{for(var i=0;i>0]=str.charCodeAt(i)}HEAP8[buffer>>0]=0};var _environ_get=(__environ,environ_buf)=>{var bufSize=0;getEnvStrings().forEach((string,i)=>{var ptr=environ_buf+bufSize;HEAPU32[__environ+i*4>>2]=ptr;stringToAscii(string,ptr);bufSize+=string.length+1});return 0};var _environ_sizes_get=(penviron_count,penviron_buf_size)=>{var strings=getEnvStrings();HEAPU32[penviron_count>>2]=strings.length;var bufSize=0;strings.forEach(string=>bufSize+=string.length+1);HEAPU32[penviron_buf_size>>2]=bufSize;return 0};var _fd_close=fd=>52;var _fd_read=(fd,iov,iovcnt,pnum)=>52;function _fd_seek(fd,offset_low,offset_high,whence,newOffset){var offset=convertI32PairToI53Checked(offset_low,offset_high);return 70}var printCharBuffers=[null,[],[]];var printChar=(stream,curr)=>{var buffer=printCharBuffers[stream];if(curr===0||curr===10){(stream===1?out:err)(UTF8ArrayToString(buffer,0));buffer.length=0}else{buffer.push(curr)}};var _fd_write=(fd,iov,iovcnt,pnum)=>{var num=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;for(var j=0;j>2]=num;return 0};var arraySum=(array,index)=>{var sum=0;for(var i=0;i<=index;sum+=array[i++]){}return sum};var MONTH_DAYS_LEAP=[31,29,31,30,31,30,31,31,30,31,30,31];var MONTH_DAYS_REGULAR=[31,28,31,30,31,30,31,31,30,31,30,31];var addDays=(date,days)=>{var newDate=new Date(date.getTime());while(days>0){var leap=isLeapYear(newDate.getFullYear());var currentMonth=newDate.getMonth();var daysInCurrentMonth=(leap?MONTH_DAYS_LEAP:MONTH_DAYS_REGULAR)[currentMonth];if(days>daysInCurrentMonth-newDate.getDate()){days-=daysInCurrentMonth-newDate.getDate()+1;newDate.setDate(1);if(currentMonth<11){newDate.setMonth(currentMonth+1)}else{newDate.setMonth(0);newDate.setFullYear(newDate.getFullYear()+1)}}else{newDate.setDate(newDate.getDate()+days);return newDate}}return newDate};function intArrayFromString(stringy,dontAddNull,length){var len=length>0?length:lengthBytesUTF8(stringy)+1;var u8array=new Array(len);var numBytesWritten=stringToUTF8Array(stringy,u8array,0,u8array.length);if(dontAddNull)u8array.length=numBytesWritten;return u8array}var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var _strftime=(s,maxsize,format,tm)=>{var tm_zone=HEAPU32[tm+40>>2];var date={tm_sec:HEAP32[tm>>2],tm_min:HEAP32[tm+4>>2],tm_hour:HEAP32[tm+8>>2],tm_mday:HEAP32[tm+12>>2],tm_mon:HEAP32[tm+16>>2],tm_year:HEAP32[tm+20>>2],tm_wday:HEAP32[tm+24>>2],tm_yday:HEAP32[tm+28>>2],tm_isdst:HEAP32[tm+32>>2],tm_gmtoff:HEAP32[tm+36>>2],tm_zone:tm_zone?UTF8ToString(tm_zone):""};var pattern=UTF8ToString(format);var EXPANSION_RULES_1={"%c":"%a %b %d %H:%M:%S %Y","%D":"%m/%d/%y","%F":"%Y-%m-%d","%h":"%b","%r":"%I:%M:%S %p","%R":"%H:%M","%T":"%H:%M:%S","%x":"%m/%d/%y","%X":"%H:%M:%S","%Ec":"%c","%EC":"%C","%Ex":"%m/%d/%y","%EX":"%H:%M:%S","%Ey":"%y","%EY":"%Y","%Od":"%d","%Oe":"%e","%OH":"%H","%OI":"%I","%Om":"%m","%OM":"%M","%OS":"%S","%Ou":"%u","%OU":"%U","%OV":"%V","%Ow":"%w","%OW":"%W","%Oy":"%y"};for(var rule in EXPANSION_RULES_1){pattern=pattern.replace(new RegExp(rule,"g"),EXPANSION_RULES_1[rule])}var WEEKDAYS=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];var MONTHS=["January","February","March","April","May","June","July","August","September","October","November","December"];function leadingSomething(value,digits,character){var str=typeof value=="number"?value.toString():value||"";while(str.length0?1:0}var compare;if((compare=sgn(date1.getFullYear()-date2.getFullYear()))===0){if((compare=sgn(date1.getMonth()-date2.getMonth()))===0){compare=sgn(date1.getDate()-date2.getDate())}}return compare}function getFirstWeekStartDate(janFourth){switch(janFourth.getDay()){case 0:return new Date(janFourth.getFullYear()-1,11,29);case 1:return janFourth;case 2:return new Date(janFourth.getFullYear(),0,3);case 3:return new Date(janFourth.getFullYear(),0,2);case 4:return new Date(janFourth.getFullYear(),0,1);case 5:return new Date(janFourth.getFullYear()-1,11,31);case 6:return new Date(janFourth.getFullYear()-1,11,30)}}function getWeekBasedYear(date){var thisDate=addDays(new Date(date.tm_year+1900,0,1),date.tm_yday);var janFourthThisYear=new Date(thisDate.getFullYear(),0,4);var janFourthNextYear=new Date(thisDate.getFullYear()+1,0,4);var firstWeekStartThisYear=getFirstWeekStartDate(janFourthThisYear);var firstWeekStartNextYear=getFirstWeekStartDate(janFourthNextYear);if(compareByDay(firstWeekStartThisYear,thisDate)<=0){if(compareByDay(firstWeekStartNextYear,thisDate)<=0){return thisDate.getFullYear()+1}return thisDate.getFullYear()}return thisDate.getFullYear()-1}var EXPANSION_RULES_2={"%a":date=>WEEKDAYS[date.tm_wday].substring(0,3),"%A":date=>WEEKDAYS[date.tm_wday],"%b":date=>MONTHS[date.tm_mon].substring(0,3),"%B":date=>MONTHS[date.tm_mon],"%C":date=>{var year=date.tm_year+1900;return leadingNulls(year/100|0,2)},"%d":date=>leadingNulls(date.tm_mday,2),"%e":date=>leadingSomething(date.tm_mday,2," "),"%g":date=>getWeekBasedYear(date).toString().substring(2),"%G":date=>getWeekBasedYear(date),"%H":date=>leadingNulls(date.tm_hour,2),"%I":date=>{var twelveHour=date.tm_hour;if(twelveHour==0)twelveHour=12;else if(twelveHour>12)twelveHour-=12;return leadingNulls(twelveHour,2)},"%j":date=>leadingNulls(date.tm_mday+arraySum(isLeapYear(date.tm_year+1900)?MONTH_DAYS_LEAP:MONTH_DAYS_REGULAR,date.tm_mon-1),3),"%m":date=>leadingNulls(date.tm_mon+1,2),"%M":date=>leadingNulls(date.tm_min,2),"%n":()=>"\n","%p":date=>{if(date.tm_hour>=0&&date.tm_hour<12){return"AM"}return"PM"},"%S":date=>leadingNulls(date.tm_sec,2),"%t":()=>"\t","%u":date=>date.tm_wday||7,"%U":date=>{var days=date.tm_yday+7-date.tm_wday;return leadingNulls(Math.floor(days/7),2)},"%V":date=>{var val=Math.floor((date.tm_yday+7-(date.tm_wday+6)%7)/7);if((date.tm_wday+371-date.tm_yday-2)%7<=2){val++}if(!val){val=52;var dec31=(date.tm_wday+7-date.tm_yday-1)%7;if(dec31==4||dec31==5&&isLeapYear(date.tm_year%400-1)){val++}}else if(val==53){var jan1=(date.tm_wday+371-date.tm_yday)%7;if(jan1!=4&&(jan1!=3||!isLeapYear(date.tm_year)))val=1}return leadingNulls(val,2)},"%w":date=>date.tm_wday,"%W":date=>{var days=date.tm_yday+7-(date.tm_wday+6)%7;return leadingNulls(Math.floor(days/7),2)},"%y":date=>(date.tm_year+1900).toString().substring(2),"%Y":date=>date.tm_year+1900,"%z":date=>{var off=date.tm_gmtoff;var ahead=off>=0;off=Math.abs(off)/60;off=off/60*100+off%60;return(ahead?"+":"-")+String("0000"+off).slice(-4)},"%Z":date=>date.tm_zone,"%%":()=>"%"};pattern=pattern.replace(/%%/g,"\0\0");for(var rule in EXPANSION_RULES_2){if(pattern.includes(rule)){pattern=pattern.replace(new RegExp(rule,"g"),EXPANSION_RULES_2[rule](date))}}pattern=pattern.replace(/\0\0/g,"%");var bytes=intArrayFromString(pattern,false);if(bytes.length>maxsize){return 0}writeArrayToMemory(bytes,s);return bytes.length-1};var _strftime_l=(s,maxsize,format,tm,loc)=>_strftime(s,maxsize,format,tm);embind_init_charCodes();BindingError=Module["BindingError"]=class BindingError extends Error{constructor(message){super(message);this.name="BindingError"}};InternalError=Module["InternalError"]=class InternalError extends Error{constructor(message){super(message);this.name="InternalError"}};init_ClassHandle();init_embind();init_RegisteredPointer();UnboundTypeError=Module["UnboundTypeError"]=extendError(Error,"UnboundTypeError");handleAllocatorInit();init_emval();var wasmImports={__assert_fail:___assert_fail,__cxa_throw:___cxa_throw,__syscall_ioctl:___syscall_ioctl,__syscall_openat:___syscall_openat,_embind_register_bigint:__embind_register_bigint,_embind_register_bool:__embind_register_bool,_embind_register_class:__embind_register_class,_embind_register_class_constructor:__embind_register_class_constructor,_embind_register_class_function:__embind_register_class_function,_embind_register_emval:__embind_register_emval,_embind_register_float:__embind_register_float,_embind_register_integer:__embind_register_integer,_embind_register_memory_view:__embind_register_memory_view,_embind_register_std_string:__embind_register_std_string,_embind_register_std_wstring:__embind_register_std_wstring,_embind_register_void:__embind_register_void,_emscripten_throw_longjmp:__emscripten_throw_longjmp,_emval_decref:__emval_decref,_emval_incref:__emval_incref,_emval_take_value:__emval_take_value,_localtime_js:__localtime_js,_mktime_js:__mktime_js,_tzset_js:__tzset_js,abort:_abort,emscripten_memcpy_big:_emscripten_memcpy_big,emscripten_resize_heap:_emscripten_resize_heap,environ_get:_environ_get,environ_sizes_get:_environ_sizes_get,fd_close:_fd_close,fd_read:_fd_read,fd_seek:_fd_seek,fd_write:_fd_write,invoke_vi:invoke_vi,invoke_viii:invoke_viii,invoke_viiii:invoke_viiii,invoke_viiiii:invoke_viiiii,strftime_l:_strftime_l};var wasmExports=createWasm();var ___wasm_call_ctors=()=>(___wasm_call_ctors=wasmExports["__wasm_call_ctors"])();var _free=a0=>(_free=wasmExports["free"])(a0);var _malloc=a0=>(_malloc=wasmExports["malloc"])(a0);var ___getTypeName=a0=>(___getTypeName=wasmExports["__getTypeName"])(a0);var __embind_initialize_bindings=Module["__embind_initialize_bindings"]=()=>(__embind_initialize_bindings=Module["__embind_initialize_bindings"]=wasmExports["_embind_initialize_bindings"])();var ___errno_location=()=>(___errno_location=wasmExports["__errno_location"])();var _setThrew=(a0,a1)=>(_setThrew=wasmExports["setThrew"])(a0,a1);var setTempRet0=a0=>(setTempRet0=wasmExports["setTempRet0"])(a0);var stackSave=()=>(stackSave=wasmExports["stackSave"])();var stackRestore=a0=>(stackRestore=wasmExports["stackRestore"])(a0);var stackAlloc=a0=>(stackAlloc=wasmExports["stackAlloc"])(a0);var ___cxa_increment_exception_refcount=a0=>(___cxa_increment_exception_refcount=wasmExports["__cxa_increment_exception_refcount"])(a0);var ___cxa_is_pointer_type=a0=>(___cxa_is_pointer_type=wasmExports["__cxa_is_pointer_type"])(a0);var dynCall_iijii=Module["dynCall_iijii"]=(a0,a1,a2,a3,a4,a5)=>(dynCall_iijii=Module["dynCall_iijii"]=wasmExports["dynCall_iijii"])(a0,a1,a2,a3,a4,a5);var dynCall_jiji=Module["dynCall_jiji"]=(a0,a1,a2,a3,a4)=>(dynCall_jiji=Module["dynCall_jiji"]=wasmExports["dynCall_jiji"])(a0,a1,a2,a3,a4);var dynCall_viijii=Module["dynCall_viijii"]=(a0,a1,a2,a3,a4,a5,a6)=>(dynCall_viijii=Module["dynCall_viijii"]=wasmExports["dynCall_viijii"])(a0,a1,a2,a3,a4,a5,a6);var dynCall_iiiiij=Module["dynCall_iiiiij"]=(a0,a1,a2,a3,a4,a5,a6)=>(dynCall_iiiiij=Module["dynCall_iiiiij"]=wasmExports["dynCall_iiiiij"])(a0,a1,a2,a3,a4,a5,a6);var dynCall_iiiiijj=Module["dynCall_iiiiijj"]=(a0,a1,a2,a3,a4,a5,a6,a7,a8)=>(dynCall_iiiiijj=Module["dynCall_iiiiijj"]=wasmExports["dynCall_iiiiijj"])(a0,a1,a2,a3,a4,a5,a6,a7,a8);var dynCall_iiiiiijj=Module["dynCall_iiiiiijj"]=(a0,a1,a2,a3,a4,a5,a6,a7,a8,a9)=>(dynCall_iiiiiijj=Module["dynCall_iiiiiijj"]=wasmExports["dynCall_iiiiiijj"])(a0,a1,a2,a3,a4,a5,a6,a7,a8,a9);function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viii(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}var calledRun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}preRun();if(runDependencies>0){return}function doRun(){if(calledRun)return;calledRun=true;Module["calledRun"]=true;if(ABORT)return;initRuntime();if(Module["onRuntimeInitialized"])Module["onRuntimeInitialized"]();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(function(){setTimeout(function(){Module["setStatus"]("")},1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run(); diff --git a/d2m/event-dispatcher.js b/d2m/event-dispatcher.js deleted file mode 100644 index 12a3507..0000000 --- a/d2m/event-dispatcher.js +++ /dev/null @@ -1,267 +0,0 @@ -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")}) */ -const sendMessage = sync.require("./actions/send-message") -/** @type {import("./actions/edit-message")}) */ -const editMessage = sync.require("./actions/edit-message") -/** @type {import("./actions/delete-message")}) */ -const deleteMessage = sync.require("./actions/delete-message") -/** @type {import("./actions/add-reaction")}) */ -const addReaction = sync.require("./actions/add-reaction") -/** @type {import("./actions/remove-reaction")}) */ -const removeReaction = sync.require("./actions/remove-reaction") -/** @type {import("./actions/announce-thread")}) */ -const announceThread = sync.require("./actions/announce-thread") -/** @type {import("./actions/create-room")}) */ -const createRoom = sync.require("./actions/create-room") -/** @type {import("./actions/create-space")}) */ -const createSpace = sync.require("./actions/create-space") -/** @type {import("./actions/update-pins")}) */ -const updatePins = sync.require("./actions/update-pins") -/** @type {import("../matrix/api")}) */ -const api = sync.require("../matrix/api") -/** @type {import("../discord/discord-command-handler")}) */ -const discordCommandHandler = sync.require("../discord/discord-command-handler") - -let lastReportedEvent = 0 - -// Grab Discord events we care about for the bridge, check them, and pass them on - -module.exports = { - /** - * @param {import("./discord-client")} client - * @param {Error} e - * @param {import("cloudstorm").IGatewayMessage} gatewayMessage - */ - 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 = e.stack.split("\n") - let cloudstormLine = stackLines.findIndex(l => l.includes("/node_modules/cloudstorm/")) - if (cloudstormLine !== -1) { - stackLines = stackLines.slice(0, cloudstormLine - 2) - } - api.sendEvent(roomID, "m.room.message", { - msgtype: "m.text", - body: "\u26a0 Bridged event from Discord not delivered. See formatted content for full details.", - format: "org.matrix.custom.html", - formatted_body: "\u26a0 Bridged event from Discord not delivered" - + `
Gateway event: ${gatewayMessage.t}` - + `
${e.toString()}` - + `
Error trace` - + `
${stackLines.join("\n")}
` - + `
Original payload` - + `
${util.inspect(gatewayMessage.d, false, 4, false)}
`, - "moe.cadence.ooye.error": { - source: "discord", - payload: gatewayMessage - }, - "m.mentions": { - user_ids: ["@cadence:cadence.moe"] - } - }) - }, - - /** - * When logging back in, check if we missed any conversations in any channels. Bridge up to 49 missed messages per channel. - * If more messages were missed, only the latest missed message will be posted. TODO: Consider bridging more, or post a warning when skipping history? - * This can ONLY detect new messages, not any other kind of event. Any missed edits, deletes, reactions, etc will not be bridged. - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayGuildCreateDispatchData} guild - */ - async checkMissedMessages(client, guild) { - if (guild.unavailable) return - const bridgedChannels = select("channel_room", "channel_id").pluck().all() - const prepared = select("event_message", "event_id", {}, "WHERE message_id = ?").pluck() - for (const channel of guild.channels.concat(guild.threads)) { - if (!bridgedChannels.includes(channel.id)) continue - if (!channel.last_message_id) continue - const latestWasBridged = prepared.get(channel.last_message_id) - if (latestWasBridged) continue - - /** More recent messages come first. */ - // console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`) - let messages - try { - messages = await client.snow.channel.getChannelMessages(channel.id, {limit: 50}) - } catch (e) { - if (e.message === `{"message": "Missing Access", "code": 50001}`) { // pathetic error handling from SnowTransfer - console.log(`[check missed messages] no permissions to look back in channel ${channel.name} (${channel.id})`) - continue // Sucks. - } else { - throw e // Sucks more. - } - } - let latestBridgedMessageIndex = messages.findIndex(m => { - return prepared.get(m.id) - }) - // 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. - for (let i = Math.min(messages.length, latestBridgedMessageIndex)-1; i >= 0; i--) { - const simulatedGatewayDispatchData = { - guild_id: guild.id, - mentions: [], - backfill: true, - ...messages[i] - } - await module.exports.onMessageCreate(client, simulatedGatewayDispatchData) - } - } - }, - - /** - * Announces to the parent room that the thread room has been created. - * See notes.md, "Ignore MESSAGE_UPDATE and bridge THREAD_CREATE as the announcement" - * @param {import("./discord-client")} client - * @param {DiscordTypes.APIThreadChannel} thread - */ - async onThreadCreate(client, thread) { - const parentRoomID = select("channel_room", "room_id", {channel_id: thread.parent_id}).pluck().get() - if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel - const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread) - await announceThread.announceThread(parentRoomID, threadRoomID, thread) - }, - - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayGuildUpdateDispatchData} guild - */ - async onGuildUpdate(client, guild) { - const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get() - if (!spaceID) return - await createSpace.syncSpace(guild) - }, - - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayChannelUpdateDispatchData} channelOrThread - * @param {boolean} isThread - */ - async onChannelOrThreadUpdate(client, channelOrThread, isThread) { - const roomID = select("channel_room", "room_id", {channel_id: channelOrThread.id}).pluck().get() - if (!roomID) return // No target room to update the data on - await createRoom.syncRoom(channelOrThread.id) - }, - - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayChannelPinsUpdateDispatchData} data - */ - async onChannelPinsUpdate(client, data) { - const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get() - if (!roomID) return // No target room to update pins in - await updatePins.updatePins(data.channel_id, roomID) - }, - - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayMessageCreateDispatchData} message - */ - async onMessageCreate(client, message) { - if (message.author.username === "Deleted User") return // Nothing we can do for deleted users. - if (message.webhook_id) { - const row = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get() - if (row) { - // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. - return - } - } - /** @type {DiscordTypes.APIGuildChannel} */ - const channel = client.channels.get(message.channel_id) - if (!channel.guild_id) return // Nothing we can do in direct messages. - const guild = client.guilds.get(channel.guild_id) - - await sendMessage.sendMessage(message, guild), - await discordCommandHandler.execute(message, channel, guild) - }, - - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayMessageUpdateDispatchData} data - */ - async onMessageUpdate(client, data) { - if (data.webhook_id) { - const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get() - if (row) { - // The update was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. - return - } - } - // 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. - if (typeof data.content === "string") { - /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ - const message = data - /** @type {DiscordTypes.APIGuildChannel} */ - const channel = client.channels.get(message.channel_id) - if (!channel.guild_id) return // Nothing we can do in direct messages. - const guild = client.guilds.get(channel.guild_id) - await editMessage.editMessage(message, guild) - } - }, - - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayMessageReactionAddDispatchData} data - */ - async onReactionAdd(client, data) { - if (data.user_id === client.user.id) return // m2d reactions are added by the discord bot user - do not reflect them back to matrix. - discordCommandHandler.onReactionAdd(data) - await addReaction.addReaction(data) - }, - - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayMessageReactionRemoveDispatchData | DiscordTypes.GatewayMessageReactionRemoveEmojiDispatchData | DiscordTypes.GatewayMessageReactionRemoveAllDispatchData} data - */ - async onSomeReactionsRemoved(client, data) { - await removeReaction.removeSomeReactions(data) - }, - - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayMessageDeleteDispatchData} data - */ - async onMessageDelete(client, data) { - await deleteMessage.deleteMessage(data) - }, - - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayTypingStartDispatchData} data - */ - async onTypingStart(client, data) { - const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get() - if (!roomID) return - const mxid = from("sim").join("sim_member", "mxid").where({user_id: data.user_id, room_id: roomID}).pluck("mxid").get() - if (!mxid) return - // Each Discord user triggers the notification every 8 seconds as long as they remain typing. - // Discord does not send typing stopped events, so typing only stops if the timeout is reached or if the user sends their message. - // (We have to manually stop typing on Matrix-side when the message is sent. This is part of the send action.) - await api.sendTyping(roomID, true, mxid, 10000) - }, - - /** - * @param {import("./discord-client")} client - * @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData | DiscordTypes.GatewayGuildStickersUpdateDispatchData} data - */ - async onExpressionsUpdate(client, data) { - await createSpace.syncSpaceExpressions(data) - } -} diff --git a/discord/discord-command-handler.js b/discord/discord-command-handler.js deleted file mode 100644 index aad06e1..0000000 --- a/discord/discord-command-handler.js +++ /dev/null @@ -1,273 +0,0 @@ -// @ts-check - -const assert = require("assert").strict -const util = require("util") -const DiscordTypes = require("discord-api-types/v10") -const reg = require("../matrix/read-registration") -const {addbot} = require("../addbot") - -const {discord, sync, db, select} = require("../passthrough") -/** @type {import("../matrix/api")}) */ -const api = sync.require("../matrix/api") -/** @type {import("../matrix/file")} */ -const file = sync.require("../matrix/file") -/** @type {import("../d2m/actions/create-space")} */ -const createSpace = sync.require("../d2m/actions/create-space") -/** @type {import("./utils")} */ -const utils = sync.require("./utils") - -const PREFIX = "//" - -let buttons = [] - -/** - * @param {string} channelID where to add the button - * @param {string} messageID where to add the button - * @param {string} emoji emoji to add as a button - * @param {string} userID only listen for responses from this user - * @returns {Promise} - */ -async function addButton(channelID, messageID, emoji, userID) { - await discord.snow.channel.createReaction(channelID, messageID, emoji) - return new Promise(resolve => { - buttons.push({channelID, messageID, userID, resolve, created: Date.now()}) - }) -} - -// Clear out old buttons every so often to free memory -setInterval(() => { - const now = Date.now() - buttons = buttons.filter(b => now - b.created < 2*60*60*1000) -}, 10*60*1000) - -/** @param {import("discord-api-types/v10").GatewayMessageReactionAddDispatchData} data */ -function onReactionAdd(data) { - const button = buttons.find(b => b.channelID === data.channel_id && b.messageID === data.message_id && b.userID === data.user_id) - if (button) { - buttons = buttons.filter(b => b !== button) // remove button data so it can't be clicked again - button.resolve(data) - } -} - -/** - * @callback CommandExecute - * @param {DiscordTypes.GatewayMessageCreateDispatchData} message - * @param {DiscordTypes.APIGuildTextChannel} channel - * @param {DiscordTypes.APIGuild} guild - * @param {Partial} [ctx] - */ - -/** - * @typedef Command - * @property {string[]} aliases - * @property {(message: DiscordTypes.GatewayMessageCreateDispatchData, channel: DiscordTypes.APIGuildTextChannel, guild: DiscordTypes.APIGuild) => Promise} execute - */ - -/** @param {CommandExecute} execute */ -function replyctx(execute) { - /** @type {CommandExecute} */ - return function(message, channel, guild, ctx = {}) { - ctx.message_reference = { - message_id: message.id, - channel_id: channel.id, - guild_id: guild.id, - fail_if_not_exists: false - } - return execute(message, channel, guild, ctx) - } -} - -/** @type {Command[]} */ -const commands = [{ - aliases: ["icon", "avatar", "roomicon", "roomavatar", "channelicon", "channelavatar"], - execute: replyctx( - async (message, channel, guild, ctx) => { - // Guard - const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() - if (!roomID) return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "This channel isn't bridged to the other side." - }) - - // Current avatar - const avatarEvent = await api.getStateEvent(roomID, "m.room.avatar", "") - const avatarURLParts = avatarEvent?.url.match(/^mxc:\/\/([^/]+)\/(\w+)$/) - let currentAvatarMessage = - ( avatarURLParts ? `Current room-specific avatar: ${reg.ooye.server_origin}/_matrix/media/r0/download/${avatarURLParts[1]}/${avatarURLParts[2]}` - : "No avatar. Now's your time to strike. Use `//icon` again with a link or upload to set the room-specific avatar.") - - // Next potential avatar - const nextAvatarURL = message.attachments.find(a => a.content_type?.startsWith("image/"))?.url || message.content.match(/https?:\/\/[^ ]+\.[^ ]+\.(?:png|jpg|jpeg|webp)\b/)?.[0] - let nextAvatarMessage = - ( nextAvatarURL ? `\nYou want to set it to: ${nextAvatarURL}\nHit ✅ to make it happen.` - : "") - - const sent = await discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: currentAvatarMessage + nextAvatarMessage - }) - - if (nextAvatarURL) { - addButton(channel.id, sent.id, "✅", message.author.id).then(async data => { - const mxcUrl = await file.uploadDiscordFileToMxc(nextAvatarURL) - await api.sendState(roomID, "m.room.avatar", "", { - url: mxcUrl - }) - db.prepare("UPDATE channel_room SET custom_avatar = ? WHERE channel_id = ?").run(mxcUrl, channel.id) - await discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "Your creation is unleashed. Any complaints will be redirected to Grelbo." - }) - }) - } - } - ) -}, { - aliases: ["invite"], - execute: replyctx( - async (message, channel, guild, ctx) => { - // Check guild is bridged - const spaceID = select("guild_space", "space_id", {guild_id: guild.id}).pluck().get() - const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() - if (!spaceID || !roomID) return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "This server isn't bridged to Matrix, so you can't invite Matrix users." - }) - - // Check CREATE_INSTANT_INVITE permission - assert(message.member) - const guildPermissions = utils.getPermissions(message.member.roles, guild.roles) - if (!(guildPermissions & BigInt(1))) { - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "You don't have permission to invite people to this Discord server." - }) - } - - // Guard against accidental mentions instead of the MXID - if (message.content.match(/<[@#:].*>/)) return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "You have to say the Matrix ID of the person you want to invite, but you mentioned a Discord user in your message.\nOne way to fix this is by writing `` ` `` backticks `` ` `` around the Matrix ID." - }) - - // Get named MXID - const mxid = message.content.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0] - if (!mxid) return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`" - }) - - // Check for existing invite to the space - let spaceMember - try { - spaceMember = await api.getStateEvent(spaceID, "m.room.member", mxid) - } catch (e) {} - if (spaceMember && spaceMember.membership === "invite") { - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `\`${mxid}\` already has an invite, which they haven't accepted yet.` - }) - } - - // Invite Matrix user if not in space - if (!spaceMember || spaceMember.membership !== "join") { - await api.inviteToRoom(spaceID, mxid) - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `You invited \`${mxid}\` to the server.` - }) - } - - // The Matrix user *is* in the space, maybe we want to invite them to this channel? - let roomMember - try { - roomMember = await api.getStateEvent(roomID, "m.room.member", mxid) - } catch (e) {} - if (!roomMember || (roomMember.membership !== "join" && roomMember.membership !== "invite")) { - const sent = await discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?\nHit ✅ to make it happen.` - }) - return addButton(channel.id, sent.id, "✅", message.author.id).then(async data => { - await api.inviteToRoom(roomID, mxid) - await discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `You invited \`${mxid}\` to the channel.` - }) - }) - } - - // The Matrix user *is* in the space and in the channel. - await discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `\`${mxid}\` is already in this server and this channel.` - }) - } - ) -}, { - aliases: ["addbot"], - execute: replyctx( - async (message, channel, guild, ctx) => { - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: addbot() - }) - } - ) -}, { - aliases: ["privacy", "discoverable", "publish", "published"], - execute: replyctx( - async (message, channel, guild, ctx) => { - const current = select("guild_space", "privacy_level", {guild_id: guild.id}).pluck().get() - if (current == null) { - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "This server isn't bridged to the other side." - }) - } - - const levels = ["invite", "link", "directory"] - const level = levels.findIndex(x => message.content.includes(x)) - if (level === -1) { - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "**Usage: `//privacy `**. This will set who can join the space on Matrix-side. There are three levels:" - + "\n`invite`: Can only join with a direct in-app invite from another Matrix user, or the //invite command." - + "\n`link`: Matrix links can be created and shared like Discord's invite links. `invite` features also work." - + "\n`directory`: Publishes to the Matrix in-app directory, like Server Discovery. Preview enabled. `invite` and `link` also work." - + `\n**Current privacy level: \`${levels[current]}\`**` - }) - } - - assert(message.member) - const guildPermissions = utils.getPermissions(message.member.roles, guild.roles) - if (guild.owner_id !== message.author.id && !(guildPermissions & BigInt(0x28))) { // MANAGE_GUILD | ADMINISTRATOR - return discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: "You don't have permission to change the privacy level. You need Manage Server or Administrator." - }) - } - - db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(level, guild.id) - discord.snow.channel.createMessage(channel.id, { - ...ctx, - content: `Privacy level updated to \`${levels[level]}\`. Changes will apply shortly.` - }) - await createSpace.syncSpaceFully(guild.id) - } - ) -}] - -/** @type {CommandExecute} */ -async function execute(message, channel, guild) { - if (!message.content.startsWith(PREFIX)) return - const words = message.content.slice(PREFIX.length).split(" ") - const commandName = words[0] - const command = commands.find(c => c.aliases.includes(commandName)) - if (!command) return - - await command.execute(message, channel, guild) -} - -module.exports.execute = execute -module.exports.onReactionAdd = onReactionAdd diff --git a/discord/utils.js b/discord/utils.js deleted file mode 100644 index 8856aa7..0000000 --- a/discord/utils.js +++ /dev/null @@ -1,60 +0,0 @@ -// @ts-check - -const DiscordTypes = require("discord-api-types/v10") - -/** - * @param {string[]} userRoles - * @param {DiscordTypes.APIGuild["roles"]} guildRoles - * @param {string} [userID] - * @param {DiscordTypes.APIGuildChannel["permission_overwrites"]} [channelOverwrites] - */ -function getPermissions(userRoles, guildRoles, userID, channelOverwrites) { - let allowed = BigInt(0) - let everyoneID - // Guild allows - for (const role of guildRoles) { - if (role.name === "@everyone") { - allowed |= BigInt(role.permissions) - everyoneID = role.id - } - if (userRoles.includes(role.id)) { - allowed |= BigInt(role.permissions) - } - } - - if (channelOverwrites) { - /** @type {((overwrite: Required["permission_overwrites"][0]) => any)[]} */ - const actions = [ - // Channel @everyone deny - overwrite => overwrite.id === everyoneID && (allowed &= ~BigInt(overwrite.deny)), - // Channel @everyone allow - overwrite => overwrite.id === everyoneID && (allowed |= BigInt(overwrite.allow)), - // Role deny - overwrite => userRoles.includes(overwrite.id) && (allowed &= ~BigInt(overwrite.deny)), - // Role allow - overwrite => userRoles.includes(overwrite.id) && (allowed |= ~BigInt(overwrite.allow)), - // User deny - overwrite => overwrite.id === userID && (allowed &= ~BigInt(overwrite.deny)), - // User allow - overwrite => overwrite.id === userID && (allowed |= BigInt(overwrite.allow)) - ] - for (let i = 0; i < actions.length; i++) { - for (const overwrite of channelOverwrites) { - actions[i](overwrite) - } - } - } - return allowed -} - -/** - * Command interaction responses have a webhook_id for some reason, but still have real author info of a real bot user in the server. - * @param {DiscordTypes.APIMessage} message - */ -function isWebhookMessage(message) { - const isInteractionResponse = message.type === 20 - return message.webhook_id && !isInteractionResponse -} - -module.exports.getPermissions = getPermissions -module.exports.isWebhookMessage = isWebhookMessage diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..4689db1 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,52 @@ +# API + +There is a web API for getting information about things that are bridged with Out Of Your Element. + +The base URL is the URL of the particular OOYE instance, for example, https://bridge.cadence.moe. + +No authentication is required. + +I'm happy to add more endpoints, just ask for them. + +## Endpoint: GET /api/message + +|Query parameter|Type|Description| +|---------------|----|-----------| +|`message_id`|regexp `/^[0-9]+$/`|Discord message ID to look up information for| + +Response: + +```typescript +{ + source: "matrix" | "discord" // Which platform the message originated on + matrix_author?: { // Only for Matrix messages; should be up-to-date rather than historical data + displayname: string, // Matrix user's current display name + avatar_url: string | null, // Absolute HTTP(S) URL to download the Matrix user's current avatar + mxid: string // Matrix user ID, can never change + }, + events: [ // Data about each individual event + { + metadata: { // Data from OOYE's database about how bridging was performed + sender: string, // Same as matrix user ID + event_id: string, // Unique ID of the event on Matrix, can never change + event_type: "m.room.message" | string, // Event type + event_subtype: "m.text" | string | null, // For m.room.message events, this is the msgtype property + part: 0 | 1, // For multi-event messages, 0 if this is the first part + reaction_part: 0 | 1, // For multi-event messages, 0 if this is the last part + room_id: string, // Room ID that the event was sent in, linked to the Discord channel + source: number + }, + raw: { // Raw historical event data from the Matrix API. Contains at least these properties: + content: any, // The only non-metadata property, entirely client-generated + type: string, + room_id: string, + sender: string, + origin_server_ts: number, + unsigned?: any, + event_id: string, + user_id: string + } + } + ] +} +``` diff --git a/docs/developer-orientation.md b/docs/developer-orientation.md new file mode 100644 index 0000000..94a420c --- /dev/null +++ b/docs/developer-orientation.md @@ -0,0 +1,129 @@ +# Development setup + +* Install development dependencies with `npm install --save-dev` so you can run the tests. +* Most files you change, such as actions, converters, and web, will automatically be reloaded. +* If developing on a different computer to the one running the homeserver, use SSH port forwarding so that Synapse can connect on its `localhost:6693` to reach the running bridge on your computer. Example: `ssh -T -v -R 6693:localhost:6693 me@matrix.cadence.moe` +* I recommend developing in Visual Studio Code so that the JSDoc x TypeScript annotation comments just work automatically. I don't know which other editors or language servers support annotations and type inference. + +# Efficiency details + +Using WeatherStack as a thin layer between the bridge application and the Discord API lets us control exactly what data is cached in memory. Only necessary information is cached. For example, member data, user data, message content, and past edits are never stored in memory. This keeps the memory usage low and also prevents it ballooning in size over the bridge's runtime. + +The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs and URLs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 300,000 messages (representing a year of conversations) requires 47.3 MB of storage space in the SQLite database. + +Only necessary data and columns are queried from the database. We only contact the homeserver API if the database doesn't contain what we need. + +File uploads (like avatars from bridged members) are checked locally and deduplicated. Only brand new files are uploaded to the homeserver. This saves loads of space in the homeserver's media repo, especially for Synapse. + +Switching to [WAL mode](https://www.sqlite.org/wal.html) could improve your database access speed even more. Run `node scripts/wal.js` if you want to switch to WAL mode. (This will also enable `synchronous = NORMAL`.) + + +# Repository structure + + . + * Runtime configuration, like tokens and user info: + ├── registration.yaml + * You are here! :) + ├── readme.md + * The bridge's SQLite database is stored here: + ├── ooye.db* + * Source code + └── src + * Database schema: + ├── db + │   ├── orm.js, orm-defs.d.ts + │   * Migrations change the database schema when you update to a newer version of OOYE: + │   ├── migrate.js + │   └── migrations + │       └── *.sql, *.js + * Discord-to-Matrix bridging: + ├── d2m + │   * Execute actions through the whole flow, like sending a Discord message to Matrix: + │   ├── actions + │   │   └── *.js + │   * Convert data from one form to another without depending on bridge state. Called by actions: + │   ├── converters + │   │   └── *.js + │   * Making Discord work: + │   ├── discord-*.js + │   * Listening to events from Discord and dispatching them to the correct `action`: + │   └── event-dispatcher.js + * Discord bot commands and menus: + ├── discord + │   ├── interactions + │   │   └── *.js + │   └── discord-command-handler.js + * Matrix-to-Discord bridging: + ├── m2d + │   * Execute actions through the whole flow, like sending a Matrix message to Discord: + │   ├── actions + │   │   └── *.js + │   * Convert data from one form to another without depending on bridge state. Called by actions: + │   ├── converters + │   │   └── *.js + │   * Listening to events from Matrix and dispatching them to the correct `action`: + │   └── event-dispatcher.js + * We aren't using the matrix-js-sdk, so here are all the functions for the Matrix C-S and Appservice APIs: + ├── matrix + │   └── *.js + * Various files you can run once if you need them. + └── scripts + * First time running a new bridge? Run this file to set up prerequisites on the Matrix server: + ├── setup.js + * Hopefully you won't need the rest of these. Code quality varies wildly. + └── *.js + +# Read next + +If you haven't set up Out Of Your Element yet, you might find [Simplified homeserver setup](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/simplified-homeserver-setup.md) helpful. + +If you don't know what the Matrix event JSON generally looks like, turn on developer tools in your client (Element has pretty good ones). Right click a couple of messages and see what they look like on the inside. + +I recommend first reading [How to add a new event type](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/how-to-add-a-new-event-type.md) as this will fill you in on key information in how the codebase is organised, which data structures are important, and what level of abstraction we're working on. + +If you haven't seen the [Discord API documentation](https://discord.com/developers/docs/) before, have a quick look at one of the pages on there. Same with the [Matrix Client-Server APIs](https://spec.matrix.org/latest/client-server-api/). You don't need to know these inside out, they're primarily references, not stories. But it is useful to have an idea of what a couple of the API endpoints look like, the kind of data they tend to accept, and the kind of data they tend to return. + +Then you might like to peruse the other files in the docs folder. Most of these were written stream-of-thought style as I try to work through a problem and find the best way to implement it. You might enjoy getting inside my head and seeing me invent and evaluate ways to solve the problem. + +Whether you read those or not, I'm more than happy to help you 1-on-1 with coding your dream feature. Join the chatroom [#out-of-your-element:cadence.moe](https://matrix.to/#/#out-of-your-element:cadence.moe) or PM me [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe) and ask away. + +# Dependency justification + +Total transitive production dependencies: 137 + +### 🦕 + +* (31) better-sqlite3: SQLite is the best database, and this is the best library for it. +* (27) @cloudrac3r/pug: Language for dynamic web pages. This is my fork. (I released code that hadn't made it to npm, and removed the heavy pug-filters feature.) +* (16) stream-mime-type@1: This seems like the best option. Version 1 is used because version 2 is ESM-only. +* (9) h3: Web server. OOYE needs this for the appservice listener, authmedia proxy, self-service, and more. +* (11) sharp: Image resizing and compositing. OOYE needs this for the emoji sprite sheets. + +### 🪱 + +* (0) @chriscdn/promise-semaphore: It does what I want. +* (1) @cloudrac3r/discord-markdown: This is my fork. +* (0) @cloudrac3r/giframe: This is my fork. +* (1) @cloudrac3r/html-template-tag: This is my fork. +* (0) @cloudrac3r/in-your-element: This is my Matrix Appservice API library. It depends on h3 and zod, which are already pulled in by OOYE. +* (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.) +* (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs. +* (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works. +* (3) @stackoverflow/stacks: Stack Overflow design language and icons. +* (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) discord-api-types: Bitfields needed at runtime and types needed for development. +* (0) domino: DOM implementation that's already pulled in by turndown. +* (1) enquirer: Interactive prompting for the initial setup rather than forcing users to edit YAML non-interactively. +* (0) entities: Looks fine. No dependencies. +* (0) get-relative-path: Looks fine. No dependencies. +* (1) heatsync: Module hot-reloader that I trust. +* (1) js-yaml: Will be removed in the future after registration.yaml is converted to JSON. +* (0) lru-cache: For holding unused nonce in memory and letting them be overwritten later if never used. +* (0) prettier-bytes: It does what I want and has no dependencies. +* (0) snowtransfer: Discord API library with bring-your-own-caching that I trust. +* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well. +* (0) uqr: QR code SVG generator. Used on the website to scan in an invite link. +* (0) xxhash-wasm: Used where cryptographically secure hashing is not required. +* (0) zod: Input validation for the web server. It's popular and easy to use. diff --git a/docs/foreign-keys.md b/docs/foreign-keys.md new file mode 100644 index 0000000..1e5e21c --- /dev/null +++ b/docs/foreign-keys.md @@ -0,0 +1,98 @@ +# Foreign keys in the Out Of Your Element database + +Historically, Out Of Your Element did not use foreign keys in the database, but since I found a need for them, I have decided to add them. Referential integrity is probably valuable as well. + +The need is that unlinking a channel and room using the web interface should clear up all related entries from `message_channel`, `event_message`, `reaction`, etc. Without foreign keys, this requires multiple DELETEs with tricky queries. With foreign keys and ON DELETE CASCADE, this just works. + +## Quirks + +* **REPLACE INTO** internally causes a DELETE followed by an INSERT, and the DELETE part **will trigger any ON DELETE CASCADE** foreign key conditions on the table, even when the primary key being replaced is the same. + * ```sql + CREATE TABLE discord_channel (channel_id TEXT NOT NULL, name TEXT NOT NULL, PRIMARY KEY (channel_id)); + CREATE TABLE discord_message (message_id TEXT NOT NULL, channel_id TEXT NOT NULL, PRIMARY KEY (message_id), + FOREIGN KEY (channel_id) REFERENCES discord_channel (channel_id) ON DELETE CASCADE); + INSERT INTO discord_channel (channel_id, name) VALUES ("c_1", "place"); + INSERT INTO discord_message (message_id, channel_id) VALUES ("m_2", "c_1"); -- i love my message + REPLACE INTO discord_channel (channel_id, name) VALUES ("c_1", "new place"); -- replace into time + -- i love my message + SELECT * FROM discord_message; -- where is my message + ``` +* In SQLite, `pragma foreign_keys = on` must be set **for each connection** after it's established. I've added this at the start of `migrate.js`, which is called by all database connections. + * Pragma? Pragma keys +* Whenever a child row is inserted, SQLite will look up a row from the parent table to ensure referential integrity. This means **the parent table should be sufficiently keyed or indexed on columns referenced by foreign keys**, or SQLite won't let you do it, with a cryptic error message later on during DML. Due to normal forms, foreign keys naturally tend to reference the parent table's primary key, which is indexed, so that's okay. But still keep this in mind, since many of OOYE's tables effectively have two primary keys, for the Discord and Matrix IDs. A composite primary key doesn't count, even when it's the first column. A unique index counts. + +## Where keys + +Here are some tables that could potentially have foreign keys added between them, and my thought process of whether foreign keys would be a good idea: + +* `guild_active` <--(PK guild_id FK)-- `channel_room` ✅ + * Could be good for referential integrity. + * Linking to guild_space would be pretty scary in case the guild was being relinked to a different space - since rooms aren't tied to a space, this wouldn't actually disturb anything. So I pick guild_active instead. +* `channel_room` <--(PK channel_id FK)-- `message_channel` ✅ + * Seems useful as we want message records to be deleted when a channel is unlinked. +* `message_channel` <--(PK message_id PK)-- `event_message` ✅ + * Seems useful as we want event information to be deleted when a channel is unlinked. +* `guild_active` <--(PK guild_id PK)-- `guild_space` ✅ + * All bridged guilds should have a corresponding guild_active entry, so referential integrity would be useful here to make sure we haven't got any weird states. +* `channel_room` <--(**C** room_id PK)-- `member_cache` ✅ + * Seems useful as we want to clear the member cache when a channel is unlinked. + * There is no index on `channel_room.room_id` right now. It would be good to create this index. Will just make it UNIQUE in the table definition. +* `message_channel` <--(PK message_id FK)-- `reaction` ✅ + * Seems useful as we want to clear the reactions cache when a channel is unlinked. +* `sim` <--(**C** mxid FK)-- `sim_member` + * OOYE inner joins on this. + * Sims are never deleted so if this was added it would only be used for enforcing referential integrity. + * The storage cost of the additional index on `sim` would not be worth the benefits. +* `channel_room` <--(**C** room_id PK)-- `sim_member` + * If a room is being permanently unlinked, it may be useful to see a populated member list. If it's about to be relinked to another channel, we want to keep the sims in the room for more speed and to avoid spamming state events into the timeline. + * Either way, the sims could remain in the room even after it's been unlinked. So no referential integrity is desirable here. +* `sim` <--(PK user_id PK)-- `sim_proxy` + * OOYE left joins on this. In normal operation, this relationship might not exist. +* `channel_room` <--(PK channel_id PK)-- `webhook` ✅ + * Seems useful. Webhooks should be deleted from Discord just before the channel is unlinked. That should be mirrored in the database too. + +## Occurrences of REPLACE INTO/DELETE FROM + +* `edit-message.js` — `REPLACE INTO message_channel` + * Scary! Changed to INSERT OR IGNORE +* `send-message.js` — `REPLACE INTO message_channel` + * Changed to INSERT OR IGNORE +* `add-reaction.js` — `REPLACE INTO reaction` +* `channel-webhook.js` — `REPLACE INTO webhook` +* `send-event.js` — `REPLACE INTO message_channel` + * Seems incorrect? Maybe?? Originally added in fcbb045. Changed to INSERT +* `event-to-message.js` — `REPLACE INTO member_cache` +* `oauth.js` — `REPLACE INTO guild_active` + * Very scary!! Changed to INSERT .. ON CONFLICT DO UPDATE +* `create-room.js` — `DELETE FROM channel_room` + * Please cascade +* `delete-message.js` + * Removed redundant DELETEs +* `edit-message.js` — `DELETE FROM event_message` +* `register-pk-user.js` — `DELETE FROM sim` + * It's a failsafe during creation +* `register-user.js` — `DELETE FROM sim` + * It's a failsafe during creation +* `remove-reaction.js` — `DELETE FROM reaction` +* `event-dispatcher.js` — `DELETE FROM member_cache` +* `redact.js` — `DELETE FROM event_message` + * Removed this redundant DELETE +* `send-event.js` — `DELETE FROM event_message` + * Removed this redundant DELETE + +## How keys + +SQLite does not have a complete ALTER TABLE command, so I have to DROP and CREATE. According to [the docs](https://www.sqlite.org/lang_altertable.html), the correct strategy is: + +1. (Not applicable) *If foreign key constraints are enabled, disable them using PRAGMA foreign_keys=OFF.* +2. Start a transaction. +3. (Not applicable) *Remember the format of all indexes, triggers, and views associated with table X. This information will be needed in step 8 below. One way to do this is to run a query like the following: SELECT type, sql FROM sqlite_schema WHERE tbl_name='X'.* +4. Use CREATE TABLE to construct a new table "new_X" that is in the desired revised format of table X. Make sure that the name "new_X" does not collide with any existing table name, of course. +5. Transfer content from X into new_X using a statement like: INSERT INTO new_X SELECT ... FROM X. +6. Drop the old table X: DROP TABLE X. +7. Change the name of new_X to X using: ALTER TABLE new_X RENAME TO X. +8. (Not applicable) *Use CREATE INDEX, CREATE TRIGGER, and CREATE VIEW to reconstruct indexes, triggers, and views associated with table X. Perhaps use the old format of the triggers, indexes, and views saved from step 3 above as a guide, making changes as appropriate for the alteration.* +9. (Not applicable) *If any views refer to table X in a way that is affected by the schema change, then drop those views using DROP VIEW and recreate them with whatever changes are necessary to accommodate the schema change using CREATE VIEW.* +10. If foreign key constraints were originally enabled then run PRAGMA foreign_key_check to verify that the schema change did not break any foreign key constraints. +11. Commit the transaction started in step 2. +12. (Not applicable) *If foreign keys constraints were originally enabled, reenable them now.* diff --git a/docs/get-started.md b/docs/get-started.md new file mode 100644 index 0000000..ae31f5f --- /dev/null +++ b/docs/get-started.md @@ -0,0 +1,108 @@ +# Setup + +If you get stuck, you're welcome to message [#out-of-your-element:cadence.moe](https://matrix.to/#/#out-of-your-element:cadence.moe) or [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe) to ask for help setting up OOYE! + +You'll need: + +* Administrative access to a homeserver +* Discord bot +* Domain name for the bridge's website - [more info](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/why-does-the-bridge-have-a-website.md) +* Reverse proxy for that domain - an interactive process will help you set this up in step 5! + +Follow these steps: + +1. [Get Node.js version 22 or later](https://nodejs.org/en/download/prebuilt-installer). If you're on Linux, you may prefer to install through system's package manager, though Debian and Ubuntu have hopelessly out of date packages. + +1. Switch to a normal user account. (i.e. do not run any of the following commands as root or sudo.) + +1. Clone this repo and checkout a specific tag. (Development happens on main. Stable versions are tagged.) + * The latest release tag is ![](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=flat-square&label=%20&color=black). + +1. Install dependencies: `npm install` + +1. Run `npm run setup` to check your setup and set the bot's initial state. You only need to run this once ever. This command will guide you precisely through the following steps: + + * First, you'll be asked for information like your homeserver URL. + + * Then you'll be prompted to set up a reverse proxy pointing from your domain to the bridge's web server. Sample configurations can be found at the end of this guide. It will check that the reverse proxy works before you continue. + + * Then you'll need to provide information about your Discord bot, and you'll be asked to change some of its settings. + + * Finally, a registration.yaml file will be generated, which you need to give to your homeserver. You'll be told how to do this. It will check that it's done properly. + +1. Start the bridge: `npm run start` + +## Update + +New versions are announced in [#updates](https://matrix.to/#/#ooye-updates:cadence.moe) and listed on [releases](https://gitdab.com/cadence/out-of-your-element/releases). Here's how to update: + +1. Fetch the repo and checkout the latest release tag. + +1. Install dependencies: `npm install` + +1. Restart the bridge: Stop the currently running process, and then start the new one with `npm run start` + +# Get Started + +Visit the website on the domain name you set up, and click the button to add the bot to your Discord server. + +* If you click the Easy Mode button, it will automatically create a Matrix room corresponding to each Discord channel. This happens next time a message is sent on Discord (so that your Matrix-side isn't immediately cluttered with lots of inactive rooms). + +* If you click the Self Service button, it won't create anything for you. You'll have to provide your own Matrix space and rooms. After you click, you'll be prompted through the process. Use this if you're migrating from another bridge! + +After that, to get into the rooms on your Matrix account, use the invite form on the website, or the `/invite [your mxid here]` command on Discord. + +I hope you enjoy Out Of Your Element! + +---- +




+ +# Appendix + +## Example reverse proxy for nginx, dedicated domain name + +Replace `bridge.cadence.moe` with the hostname you're using. + +```nix +server { + listen 80; + listen [::]:80; + server_name bridge.cadence.moe; + + return 301 https://bridge.cadence.moe$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name bridge.cadence.moe; + + # ssl parameters here... + client_max_body_size 5M; + + location / { + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + proxy_pass http://127.0.0.1:6693; + } +} +``` + +## Example reverse proxy for nginx, sharing a domain name + +Same as above, but change the following: + +- `location / {` -> `location /ooye/ {` (any sub-path you want; you MUST use a trailing slash or it won't work) +- `proxy_pass http://127.0.0.1:6693;` -> `proxy_pass http://127.0.0.1:6693/;` (you MUST use a trailing slash on this too or it won't work) + +## Example reverse proxy for Caddy, dedicated domain name + +```nix +bridge.cadence.moe { + log { + output file /var/log/caddy/access.log + format console + } + encode gzip + reverse_proxy 127.0.0.1:6693 +} +``` diff --git a/docs/how-to-add-a-new-event-type.md b/docs/how-to-add-a-new-event-type.md index 10beec1..ec9cd7b 100644 --- a/docs/how-to-add-a-new-event-type.md +++ b/docs/how-to-add-a-new-event-type.md @@ -32,13 +32,13 @@ What does it look like on Discord-side? This is an API request to get the pinned messages. To update this, an API request will pin or unpin any specific message, adding or removing it from the list. -## What will the converter look like? +## What will the converter do? The converter will be very different in both directions. -For d2m, we will get the list of pinned messages, we will convert each message ID into the ID of an event we already have, and then we will set the entire `m.room.pinned_events` state to that list. +**For d2m, we will get the list of pinned messages, we will convert each message ID into the ID of an event we already have, and then we will set the entire `m.room.pinned_events` state to that list.** -For m2d, we will have to diff the list of pinned messages against the previous version of the list, and for each event that was pinned or unpinned, we will send an API request to Discord to change its state. +**For m2d, we will have to diff the list of pinned messages against the previous version of the list, and for each event that was pinned or unpinned, we will send an API request to Discord to change its st**ate. ## Missing messages @@ -53,7 +53,7 @@ In this situation we need to stop and think about the possible paths forward we The latter method would still make the message appear at the bottom of the timeline for most Matrix clients, since for most the timestamp doesn't determine the actual _order._ It would then be confusing why an odd message suddenly appeared, because a pins change isn't that noticable in the room. -To avoid this problem, I'll just go with the former method and ignore the message, so Matrix will only have some of the pins that Discord has. We will need to watch out if a Matrix user edits this list of partial pins, because if we _only_ pinned things on Discord that were pinned on Matrix, those partial pins Discord would be lost from Discord side. +To avoid this problem, I'll just go with the former method and ignore the message, so Matrix will only have some of the pins that Discord has. We will need to watch out if a Matrix user edits this list of partial pins, because if we _only_ pinned things on Discord that were pinned on Matrix, then pins Matrix doesn't know about would be lost from Discord side. In this situation I will prefer to keep the pins list inconsistent between both sides and only bridge _changes_ to the list. @@ -61,7 +61,9 @@ If you were implementing this for real, you might have made different decisions ## Test data for the d2m converter -Let's start writing the d2m converter. It's helpful to write unit tests for Out Of Your Element, since this lets you check if it worked without having to start up a local copy of the bridge or play around with the interface. +Let's start writing the d2m converter. It's helpful to write automated tests for Out Of Your Element, since this lets you check if it worked without having to start up a local copy of the bridge or mess around with the interface. + +To test the Discord-to-Matrix pin converter, we'll need some samples of Discord message objects. Then we can put these sample message objects through the converter and check what comes out the other side. Normally for getting test data, I would `curl` the Discord API to grab some real data and put it into `data.js` (and possibly also `ooye-test-data.sql`. But this time, I'll fabricate some test data. Here it is: @@ -74,7 +76,7 @@ Normally for getting test data, I would `curl` the Discord API to grab some real ] ``` -"These aren't message objects!" I hear you cry. Correct. I already know that my implementation is not going to care about any properties on these message object other than the IDs, so I'm just making a list of IDs to save time. +"These aren't message objects!" I hear you cry. Correct. I already know that my implementation is not going to care about any properties on these message object other than the IDs, so to save time, I'm just making a list of IDs. These IDs were carefully chosen. The first three are already in `ooye-test-data.sql` and are associated with event IDs. This is great, because in our test case, the Discord IDs will be converted to those event IDs. The fourth ID doesn't exist on Matrix-side. This is to test that partial pins are handled as expected, like I wrote in the previous section. @@ -104,7 +106,7 @@ index c36f252..4919beb 100644 ## Writing the d2m converter -We can write a function that operates on this data to convert it to events. This is a _converter,_ not an _action._ it won't _do_ anything by itself. So it goes in the converters folder. The actual function is pretty simple since I've already planned what to do: +We can write a function that operates on this data to convert it to events. This is a _converter,_ not an _action._ It won't _do_ anything by itself. So it goes in the converters folder. I've already planned (in the "What will the converter do?" section) what to do, so writing the function is pretty simple: ```diff diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js @@ -133,9 +135,36 @@ index 0000000..e4107be +module.exports.pinsToList = pinsToList ``` +### Explaining the code + +All converters have a `function` which does the work, and the function is added to `module.exports` so that other files can use it. + +Importing `select` from `passthrough` lets us do database access. Calling the `select` function can select from OOYE's own SQLite database. If you want to see what's in the database, look at `ooye-test-data.sql` for test data, or open `ooye.db` for real data from your own bridge. + +The comments `// @ts-check`, `/** @type ... */`, and `/** @param ... */` provide type-based autosuggestions when editing in Visual Studio Code. + +Here's the code I haven't yet discussed: + +```js +function pinsToList(pins) { + const result = [] + for (const message of pins) { + const eventID = select("event_message", "event_id", {message_id: message.id}).pluck().get() + if (eventID) result.push(eventID) + } + return result +} +``` + +It will go through each `message` in `pins`. For each message, it will look up the corresponding Matrix event in the database, and if found, it will add it to `result`. + +The `select` line will run this SQL: `SELECT event_id FROM event_message WHERE message_id = {the message ID}` and will return the event ID as a string or null. + +For any database experts worried about an SQL query inside a loop, the N+1 problem does not apply to SQLite because the queries are executed in the same process rather than crossing a process (and network) boundary. https://www.sqlite.org/np1queryprob.html + ## Test case for the d2m converter -There's not much room for bugs in this function. A single manual test that it works would be good enough for me. But since this is an example of how you can add your own, let's add a test case for this. We'll take the data we just prepared and process it through the function we just wrote: +There's not much room for bugs in this function. A single manual test that it works would be good enough for me. But since this is an example of how you can add your own, let's add a test case for this. The testing code will take the data we just prepared and process it through the `pinsToList` function we just wrote. Then, it will check the result is what we expected. ```diff diff --git a/d2m/converters/pins-to-list.test.js b/d2m/converters/pins-to-list.test.js @@ -177,6 +206,18 @@ index 5cc851e..280503d 100644 Good to go. +### Explaining the code + +`require("supertape")` is a library that helps with testing and printing test results. `data = require("../../test/data")` is the file we edited earlier in the "Test data for the d2m converter" section. `require("./pins-to-list")` is the function we want to test. + +Here is how you declare a test: `test("pins2list: converts known IDs, ignores unknown IDs", t => {` The string describes what you are trying to test and it will be displayed if the test fails. + +`result = pinsToList(data.pins.faked)` is calling the implementation function we wrote. + +`t.deepEqual(actual, expected)` will check whether the `actual` result value is the same as our `expected` result value. If it's not, it'll mark that as a failed test. + +### Run the test! + ``` ><> $ npm t @@ -201,15 +242,19 @@ Good to go. at module.exports (out-of-your-element/node_modules/try-to-catch/lib/try-to-catch.js:7:29) ``` -Oh, this was actually an accident, I didn't make it fail for demonstration purposes! Let's see what this bug is. It's returning the right number of IDs, but 2 out of the 3 are incorrect. The green `-` lines are "expected" and the red `+` lines are "actual". I should check where that wrong ID `$51f...` got taken from. +Oh no! (I promise I didn't make it fail for demonstration purposes, this was actually an accident!) Let's see what this bug is. It's returning the right number of IDs, but 2 out of the 3 are incorrect. The green `-` lines are "expected" and the red `+` lines are "actual". The wrong ID `$51f...` must have been taken from _somewhere_ in the test data, so I'll first search the codebase and find where it came from: -``` - - snip - ooye-test-data.sql +```sql +-- snipped from ooye-test-data.sql ('$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA', 'm.room.message', 'm.text', '1141501302736695316', 0, 1), ('$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI', 'm.room.message', 'm.image', '1141501302736695316', 1, 1), ``` -Context: This Discord message `1141501302736695316` is actually part of 2 different Matrix events, `$mtR...` and `$51f...`. This often happens when a Discord user uploads an image with a caption. Matrix doesn't support combined image+text events, so the image and the text have to be bridged to separate events. We should consider the text to be the primary part, and pin that, and consider the image to be the secondary part, and not pin that. +Explanation: This Discord message `1141501302736695316` is actually part of 2 different Matrix events, `$mtR...` and `$51f...`. This often happens when a Discord user uploads an image with a caption. Matrix doesn't support combined image+text events, so the image and the text have to be bridged to separate events. + +In the current code, `pinsToList` is picking ALL the associated event IDs, and then `.get` is forcing it to limit that list to 1. It doesn't care which, so it's essentially random which event it wants to pin. + +We should make a decision on which event is more important. You can make whatever decision you want - you could even make it pin every event associated with a message - but I've decided that the text should be the primary part and be pinned, and the image should be considered a secondary part and left unpinned. We already have a column `part` in the `event_message` table for this reason! When `part = 0`, that's the primary part. I'll edit the converter to actually use that column: @@ -229,6 +274,8 @@ index e4107be..f401de2 100644 return result ``` +As long as the database is consistent, this new `select` will return at most 1 event, always choosing the primary part. + ``` ><> $ npm t @@ -269,7 +316,7 @@ index 83c31cd..4de84d9 100644 await eventDispatcher.onThreadCreate(client, message.d) ``` -`event-dispatcher.js` will now check if the event seems reasonable and is allowed in this context. For example, we can only update pins if the channel is actually bridged somewhere. This should be another quick check which passes to an action to do the API calls: +`event-dispatcher.js` will now check if the event seems reasonable and is allowed in this context. For example, we can only update pins if the channel is actually bridged somewhere. After the check, we'll call the action: ```diff diff --git a/d2m/event-dispatcher.js b/d2m/event-dispatcher.js @@ -304,7 +351,7 @@ index 0f9f1e6..6e91e9e 100644 * @param {DiscordTypes.GatewayMessageCreateDispatchData} message ``` -And now I can create the `update-pins.js` action: +And now I can write the `update-pins.js` action: ```diff diff --git a/d2m/actions/update-pins.js b/d2m/actions/update-pins.js @@ -339,6 +386,76 @@ index 0000000..40cc358 I try to keep as much logic as possible out of the actions and in the converters. This should mean I *never have to unit test the actions themselves.* The actions will be tested manually with the real bot. +## See if it works + +Since the automated tests pass, let's start up the bridge and run our nice new code: + +``` +node start.js +``` + +We can try these things and see if they are bridged to Matrix: + +- Pin a recent message on Discord-side +- Pin an old message on Discord-side +- Unpin a message on Discord-side + +It works like I'd expect! + +## Order of pinned messages + +I expected that to be the end of the guide, but after some time, I noticed a new problem: The pins are in reverse order. How could this happen? + +[After some investigation,](https://gitdab.com/cadence/out-of-your-element/issues/16) it turns out Discord puts the most recently pinned message at the start of the array and displays the array in forwards order, while Matrix puts the most recently pinned message at the end of the array and displays the array in reverse order. + +We can fix this by reversing the order of the list of pins before we store it. The converter can do this: + +```diff +diff --git a/d2m/converters/pins-to-list.js b/d2m/converters/pins-to-list.js +index f401de2..047bb9f 100644 +--- a/d2m/converters/pins-to-list.js ++++ b/d2m/converters/pins-to-list.js +@@ -12,6 +12,7 @@ function pinsToList(pins) { + const eventID = select("event_message", "event_id", {message_id: message.id, part: 0}).pluck().get() + if (eventID) result.push(eventID) + } ++ result.reverse() + return result + } +``` + +Since the results have changed, I'll need to update the test so it expects the new result: + +```diff +diff --git a/d2m/converters/pins-to-list.test.js b/d2m/converters/pins-to-list.test.js +index c2e3774..92e5678 100644 +--- a/d2m/converters/pins-to-list.test.js ++++ b/d2m/converters/pins-to-list.test.js +@@ -5,8 +5,8 @@ const {pinsToList} = require("./pins-to-list") + test("pins2list: converts known IDs, ignores unknown IDs", t => { + const result = pinsToList(data.pins.faked) + t.deepEqual(result, [ +- "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", +- "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", +- "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4" ++ "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", ++ "$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA", ++ "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" + ]) + }) +``` + +``` +><> $ npm t + + 144 tests + 232 passed + + Pass! +``` + +Next time a message is pinned or unpinned on Discord, OOYE should update the order of all the pins on Matrix. + ## Notes on missed events Note that this will only sync pins _when the pins change._ Existing pins from Discord will not be backfilled to Matrix rooms. If I wanted, there's a couple of ways I could address this: @@ -346,4 +463,4 @@ Note that this will only sync pins _when the pins change._ Existing pins from Di * I could create a one-shot script in `scripts/update-pins.js` which will sync pins for _all_ Discord channels right away. I can run this after finishing the feature, or if the bot has been offline for some time. * I could create a database table that holds the timestamp of the most recently detected pin for each channel - the `last_pin_timestamp` field from the gateway. Every time the bot starts, it would automatically compare the database table against every channel, and if the pins have changed since it last looked, it could automatically update them. -I already have a mechanism for backfilling missed messages when the bridge starts up. Option 2 there would add a similar feature for backfilling missed pins. That could be worth considering, but it's less important and more complex. Perhaps we'll come back to it. +I already have code to backfill missed messages when the bridge starts up. The second option above would add a similar feature for backfilling missed pins. It would be worth considering. diff --git a/docs/pluralkit-notetaking.md b/docs/pluralkit-notetaking.md new file mode 100644 index 0000000..d03ddb7 --- /dev/null +++ b/docs/pluralkit-notetaking.md @@ -0,0 +1,98 @@ +## What is PluralKit + +PluralKit is a Discord bot. After a Discord user registers with PK, PK will delete and repost their messages. The reposted messages will be sent by a webhook with a custom display name and avatar. This effectively lets a person assume a custom display name and avatar at will on a per-message basis. People use this for roleplaying and/or dissociative-identity-disorder things. PK is extremely popular. + +## PK terminology + +- **Proxying:** The act of deleting and reposting messages. +- **Member:** Identity that messages will be posted by. +- **System:** Systems contain members. A system is usually controlled by one Discord account, but it's also possible to have multiple accounts be part of the same system. + +## PK API schema + +https://pluralkit.me/api/models/ + +## Experience on OOYE without special PK handling + +1. Message is sent by Discord user and copied to Matrix-side. +1. The message is immediately deleted by PK and deleted from Matrix-side. +1. The message is resent by the PK webhook and copied to Matrix-side (by @_ooye_bot) with limited authorship information. + +## Experience on Half-Shot's bridge without special PK handling + +1. Message is sent by Discord user and copied to Matrix-side. +1. The message is immediately deleted by PK and deleted from Matrix-side. +1. The message is resent by the PK webhook and copied to Matrix-side _by a dedicated sim user for that webhook's username._ + +If a PK system member changes their display name, the webhook display name will change too. But Half-Shot's bridge can't keep track of webhook message authorship. It uses the webhook's display name to determine whether to reuse the previous sim user account. This makes Half-Shot's bridge create a brand new sim user for the same system member, and causes the Matrix-side member list to eventually fill up with lots of abandoned sim users named @_discord_NUMBERS_NUMBERS_GARBLED_NAME. + +## Goals of special PK handling + +1. Avoid bridging the send-delete-send dance (solution: the speedbump) +2. Attribute message authorship to the actual PK system member (solution: system member mapping) +3. Avoid creating too many sim users (solution: OOYE sending other webhook messages as @_ooye_bot) + +## What is the speedbump (goal 1) + +When a Discord user sends a message, we can't know whether or not it's about to be deleted by PK. + +If PK doesn't plan to delete the message, we should deliver it straight away to Matrix-side. + +But if PK does plan to delete the message, we shouldn't bridge it at all. We should wait until the PK webhook sends the replacement message, then deliver _that_ message to Matrix-side. + +Unfortunately, we can't see into the future. We don't know if PK will delete the message or not. + +My solution is the speedbump. In speedbump-enabled channels, OOYE will wait a few seconds before delivering the message. The **purpose of the speedbump is to avoid the send-delete-send dance** by not bridging a message until we know it's supposed to stay. + +## Configuring the speedbump + +Nuh-uh. Offering configuration creates an opportunity for misconfiguration. OOYE wants to act in the best possible way with the default settings. In general, everything in OOYE should work in an intelligent, predictable way without having to think about it. + +Since it slows down messages, the speedbump has a negative impact on user experience if it's not needed. So OOYE will automatically activate and deactivate the speedbump if it's necessary. Here's how it works. + +When a message is deleted in a channel, the following logic is triggered: + +1. Discord API: Get the list of webhooks in this channel. +1. If there is a webhook owned by PK, speedbump mode is now ON. Otherwise, speedbump mode is now OFF. + +This check is only done every so often to avoid encountering the Discord API's rate limits. + +## PK system member mapping (goal 2) + +PK system members need to be mapped to individual Matrix sim users, so we need to map the member data to all the fields of a Matrix profile. (This will replace the existing logic of `userToSimName`.) I'll map them in this way: + +- **Matrix ID:** `@_ooye_pk_[FIVE_CHAR_ID].example.org` +- **Display name:** `[NAME] [[PRONOUNS]]` +- **Avatar:** webhook_avatar_url ?? avatar_url + +I'll get this data by calling the PK API for each message: https://api.pluralkit.me/v2/messages/[PK_WEBHOOK_MESSAGE_ID] + +## Special code paths for PK users + +When a message is deleted, re-evaluate speedbump mode if necessary, and store who the PK webhook is for this channel if exists. + +When a message is received and the speedbump is enabled, put it into a queue to be sent a few seconds later. + +When a message is deleted, remove it from the queue. + +When a message is received, if it's from a webhook, and the webhook is in the "speedbump_webhook" table, and the webhook user ID is the public PK instance, then look up member details in the PK API, and use a different MXID mapping algorithm based on those details. + +### Edits should Just Work without any special code paths + +Proxied messages are edited by sending "pk;edit blah blah" as a reply to the message to edit. PK will delete the edit command and use the webhook edit endpoint to update the message. + +OOYE's speedbump will prevent the edit command appearing at all on Matrix-side, and OOYE already understands how to do webhook edits. + +## Database schema + +* channel_room + + speedbump_id - the ID of the webhook that may be proxying in this channel + + speedbump_checked - time in unix seconds when the webhooks were last queried + +## Unsolved problems + +- Improve the contents of PK's reply embeds to be the actual reply text, not the OOYE context preamble +- Possibly change OOYE's reply context to be an embed (for visual consistency with replies from PK users) +- Possibly extract information from OOYE's reply embed and transform it into an mx-reply structure for Matrix users +- Unused or removed system members should be removed from the member list too. +- When a Discord user leaves a server, all their system members should leave the member list too. (I also have to solve this for regular non-PK users.) diff --git a/docs/self-service-room-creation-rules.md b/docs/self-service-room-creation-rules.md new file mode 100644 index 0000000..4156f26 --- /dev/null +++ b/docs/self-service-room-creation-rules.md @@ -0,0 +1,77 @@ +# Self-service room creation rules + +Before version 3 of Out Of Your Element, new Matrix rooms would be created on-demand when a Discord channel is spoken in for the first time. This has worked pretty well. + +This is done through functions like ensureRoom and ensureSpace in actions: + +```js +async function sendMessage(message, channel, guild, row) { + const roomID = await createRoom.ensureRoom(message.channel_id) + ... +} + +/** + * Ensures the room exists. If it doesn't, creates the room with an accurate initial state. + * @param {string} channelID + * @returns {Promise} Matrix room ID + */ +function ensureRoom(channelID) { + return _syncRoom(channelID, /* shouldActuallySync */ false) /* calls ensureSpace */ +} + +/** + * Ensures the space exists. If it doesn't, creates the space with an accurate initial state. + * @param {DiscordTypes.APIGuild} guild + * @returns {Promise} Matrix space ID + */ +function ensureSpace(guild) { + return _syncSpace(guild, /* shouldActuallySync */ false) +} +``` + +With the introduction of self-service mode, we still want to retain this as a possible mode of operation, since some people prefer to have OOYE handle this administrative work. However, other people prefer to manage the links between channels and rooms themselves, and have control over what new rooms get linked up to. + +Visibly, this is managed through the web interface. The web interface lets moderators enable/disable auto-creation of new rooms, as well as set which channels and rooms are linked together. + +There is a small complication. Not only are Matrix rooms created automatically, their Matrix spaces are also created automatically during room sync: ensureRoom calls ensureSpace. If a user opts in to self-service mode by clicking the specific button in the web portal, we must ensure the _space is not created automatically either,_ because the Matrix user will provide a space to link to. + +To solve this, we need a way to suppress specific guilds from having auto-created spaces. The natural way to represent this is a column on guild_space, but that doesn't work, because each guild_space row requires a guild and space to be linked, and we _don't want_ them to be linked. + +So, internally, OOYE keeps track of this through a new table: + +```sql +CREATE TABLE "guild_active" ( + "guild_id" TEXT NOT NULL, -- only guilds that are bridged are present in this table + "autocreate" INTEGER NOT NULL, -- 0 or 1 + PRIMARY KEY("guild_id") +) WITHOUT ROWID; +``` + +There is one more complication. When adding a Discord bot through web oauth with a redirect_uri, Discord adds the bot to the server normally, _then_ redirects back to OOYE, and only then does OOYE know which guild the bot was just added to. So, for a short time between the bot being added and the user being redirected, OOYE might receive Discord events in the server before it has the chance to create the guild_active database row. + +So to prevent this, self-service behaviour needs to be an implicit default, and users must firmly choose one system or another to begin using OOYE. It is important for me to design this in a way that doesn't force users to do any extra work or make a choice they don't understand to keep the pre-v3 behaviour. + +So there will be 3 states of whether a guild is self-service or not. At first, it could be absent from the table, in which case events for it will be dropped. Or it could be in the table with autocomplete = 0, in which case only rooms that already exist in channel_room will have messages bridged. Or it could have autocomplete = 1, in which case Matrix rooms will be created as needed, as per the pre-v3 behaviour. + +| Auto-create | Meaning | +| -- | ------------ | +| 😶‍🌫️ | Unbridged - waiting | +| ❌ | Bridged - self-service | +| ✅ | Bridged - auto-create | + +Pressing buttons on web or using the /invite command on a guild will insert a row into guild_active, allowing it to be bridged. + +So here's all the technical changes needed to support self-service in v3: + +- New guild_active table showing whether, and how, a guild is bridged. +- When /invite command is used, INSERT OR IGNORE INTO state 1 and ensureRoom + ensureSpace. +- When bot is added through "easy mode" web button, REPLACE INTO state 1 and ensureSpace. +- When bot is added through "self-service" web button, REPLACE INTO state 0. +- Event dispatcher will only ensureRoom if the guild_active state is 1. +- createRoom will only create other dependencies if the guild is autocreate. + +## Enough with your theory. How do rooms actually get bridged now? + +After clicking the easy mode button on web and adding the bot to a server, it will create new Matrix rooms on-demand when any invite features are used (web or command) OR just when any message is sent on Discord. + +Alternatively, pressing the self-service mode button and adding the bot to a server will prompt the web user to link it with a space. After doing so, they'll be on the standard guild management page where they can invite to the space and manually link rooms. Nothing will be autocreated. diff --git a/docs/why-does-the-bridge-have-a-website.md b/docs/why-does-the-bridge-have-a-website.md new file mode 100644 index 0000000..387f59b --- /dev/null +++ b/docs/why-does-the-bridge-have-a-website.md @@ -0,0 +1,55 @@ +# Why does the bridge have a website? + +## It's essential for making images work + +Matrix has a feature called [Authenticated Media](https://matrix.org/blog/2024/06/26/sunsetting-unauthenticated-media/), where uploaded media (like user avatars and uploaded files) is restricted to Matrix users only. This means Discord users wouldn't be able to see important parts of the conversation. + +To keep things working for Discord users, OOYE's web server can act as a proxy files that were uploaded on Matrix-side. This will automatically take effect when needed, so Discord users shouldn't notice any issues. + +## Why now? + +I knew a web interface had a lot of potential, but I was reluctant to add one because it would make the initial setup more complicated. However, when authenticated media forced my hand, I saw an opportunity to introduce new useful features. Hopefully you'll agree that it's worth it! + +# What else does it do? + +## Makes it easy to invite the bot + +The home page of the website has buttons to add the bridge bot to a Discord server. If you are primarly a Matrix user and you want somebody else (who may be less technical) to add the bot to their own Discord server, this should make it a lot more intuitive for them. + +## Makes it easy to invite yourself or others + +After your hypothetical less-technical friend adds the bot to their Discord server, you need to generate an invite for your Matrix account on Matrix-side. Without the website, you might need to guide them through running a /invite command with your user ID. With the website, they don't have to do anything extra. You can use your phone to scan the QR code on their screen, which lets you invite your user ID in your own time. + +You can also set the person's permissions when you invite them, so you can easily bootstrap the Matrix side with your trusted ones as moderators. + +## To link channels and rooms + +Without a website, to link Discord channels to existing Matrix rooms, you'd need to run a /link command with the internal IDs of each room. This is tedious and error-prone, especially if you want to set up a lot of channels. With the web interface, you can see a list of all the available rooms and click them to link them together. + +## To change settings + +Important settings, like whether the Matrix rooms should be private or publicly accessible, can be configured by clicking buttons rather than memorising commands. Changes take effect immediately. + +# Permissions + +## Bot invites + +Anybody who can access the home page can use the buttons to add your bot - but even without the website, they can already do this by manually constructing a URL. If you want to make it so _only you_ can add your bot to servers, you need to edit [your Discord application](https://discord.com/developers/applications), go to the Bot section, and turn off the switch that says Public Bot. + +## Server settings + +If you have either the Administrator or Manage Server permissions in a Discord server, you can use the website to manage that server, including linking channels and changing settings. + +# Initial setup + +The website is built in to OOYE and is always running as part of the bridge. For authenticated media proxy to work, you'll need to make the web server accessible on the public internet over HTTPS, presumably using a reverse proxy. + +When you use `npm run setup` as part of OOYE's initial setup, it will guide you through this process, and it will do a thorough self-test to make sure it's configured correctly. If you get stuck or want a configuration template, check the notes below. + +## Reverse proxy + +When OOYE is running, the web server runs on port 6693. (To use a different port or a UNIX socket, edit registration.yaml's `socket` setting and restart.) + +It doesn't have to have its own dedicated domain name, you can also use a sub-path on an existing domain, like the domain of your Matrix homeserver. You are likely already using a reverse proxy to run your homeserver, so this should just be a configuration change. + +[See here for sample configurations!](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md#appendix) diff --git a/m2d/converters/emoji-sheet.js b/m2d/converters/emoji-sheet.js deleted file mode 100644 index c05f45d..0000000 --- a/m2d/converters/emoji-sheet.js +++ /dev/null @@ -1,104 +0,0 @@ -// @ts-check - -const assert = require("assert").strict -const {pipeline} = require("stream").promises -const sharp = require("sharp") -const {GIFrame} = require("giframe") -const utils = require("./utils") -const fetch = require("node-fetch").default -const streamMimeType = require("stream-mime-type") - -const SIZE = 48 -const RESULT_WIDTH = 400 -const IMAGES_ACROSS = Math.floor(RESULT_WIDTH / SIZE) - -/** - * Composite a bunch of Matrix emojis into a kind of spritesheet image to upload to Discord. - * @param {string[]} mxcs mxc URLs, in order - * @returns {Promise} PNG image - */ -async function compositeMatrixEmojis(mxcs) { - let buffers = await Promise.all(mxcs.map(async mxc => { - const abortController = new AbortController() - - try { - const url = utils.getPublicUrlForMxc(mxc) - assert(url) - - /** @type {import("node-fetch").Response} res */ - // If it turns out to be a GIF, we want to abandon the connection without downloading the whole thing. - // If we were using connection pooling, we would be forced to download the entire GIF. - // So we set no agent to ensure we are not connection pooling. - // @ts-ignore the signal is slightly different from the type it wants (still works fine) - const res = await fetch(url, {agent: false, signal: abortController.signal}) - const {stream, mime} = await streamMimeType.getMimeType(res.body) - - if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp") { - /** @type {{info: sharp.OutputInfo, buffer: Buffer}} */ - const result = await new Promise((resolve, reject) => { - const transformer = sharp() - .resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}}) - .png({compressionLevel: 0}) - .toBuffer((err, buffer, info) => { - /* c8 ignore next */ - if (err) return reject(err) - resolve({info, buffer}) - }) - pipeline( - stream, - transformer - ) - }) - return result.buffer - - } else if (mime === "image/gif") { - const giframe = new GIFrame(0) - stream.on("data", chunk => { - giframe.feed(chunk) - }) - const frame = await giframe.getFrame() - - const buffer = await sharp(frame.pixels, {raw: {width: frame.width, height: frame.height, channels: 4}}) - .resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}}) - .png({compressionLevel: 0}) - .toBuffer({resolveWithObject: true}) - return buffer.data - - } else { - // unsupported mime type - console.error(`I don't know what a ${mime} emoji is.`) - return null - } - } finally { - abortController.abort() - } - })) - - // Calculate the size of the final composited image - const totalWidth = Math.min(buffers.length, IMAGES_ACROSS) * SIZE - const imagesDown = Math.ceil(buffers.length / IMAGES_ACROSS) - const totalHeight = imagesDown * SIZE - const comp = [] - let left = 0, top = 0 - for (const buffer of buffers) { - if (Buffer.isBuffer(buffer)) { - // Composite the current buffer into the sprite sheet - comp.push({left, top, input: buffer}) - // The next buffer should be placed one slot to the right - left += SIZE - // If we're out of space to fit the entire next buffer there, wrap to the next line - if (left + SIZE > RESULT_WIDTH) { - left = 0 - top += SIZE - } - } - } - - const output = await sharp({create: {width: totalWidth, height: totalHeight, channels: 4, background: {r: 0, g: 0, b: 0, alpha: 0}}}) - .composite(comp) - .png() - .toBuffer({resolveWithObject: true}) - return output.data -} - -module.exports.compositeMatrixEmojis = compositeMatrixEmojis diff --git a/m2d/converters/emoji.js b/m2d/converters/emoji.js deleted file mode 100644 index 2c39a86..0000000 --- a/m2d/converters/emoji.js +++ /dev/null @@ -1,55 +0,0 @@ -// @ts-check - -const assert = require("assert").strict -const Ty = require("../../types") - -const passthrough = require("../../passthrough") -const {sync, select} = passthrough - -/** - * @param {string} input - * @param {string | null | undefined} shortcode - * @returns {string?} - */ -function encodeEmoji(input, shortcode) { - let discordPreferredEncoding - if (input.startsWith("mxc://")) { - // Custom emoji - let row = select("emoji", ["emoji_id", "name"], {mxc_url: input}).get() - if (!row && shortcode) { - // Use the name to try to find a known emoji with the same name. - const name = shortcode.replace(/^:|:$/g, "") - row = select("emoji", ["emoji_id", "name"], {name: name}).get() - } - if (!row) { - // We don't have this emoji and there's no realistic way to just-in-time upload a new emoji somewhere. - // Sucks! - return null - } - // Cool, we got an exact or a candidate emoji. - discordPreferredEncoding = encodeURIComponent(`${row.name}:${row.emoji_id}`) - } else { - // Default emoji - // https://github.com/discord/discord-api-docs/issues/2723#issuecomment-807022205 ???????????? - const encoded = encodeURIComponent(input) - const encodedTrimmed = encoded.replace(/%EF%B8%8F/g, "") - - const forceTrimmedList = [ - "%F0%9F%91%8D", // 👍 - "%F0%9F%91%8E", // 👎️ - "%E2%AD%90", // ⭐ - "%F0%9F%90%88", // 🐈 - "%E2%9D%93", // ❓ - ] - - discordPreferredEncoding = - ( forceTrimmedList.includes(encodedTrimmed) ? encodedTrimmed - : encodedTrimmed !== encoded && [...input].length === 2 ? encoded - : encodedTrimmed) - - console.log("add reaction from matrix:", input, encoded, encodedTrimmed, "chosen:", discordPreferredEncoding) - } - return discordPreferredEncoding -} - -module.exports.encodeEmoji = encodeEmoji diff --git a/m2d/converters/event-to-message.js b/m2d/converters/event-to-message.js deleted file mode 100644 index 5f6f3e6..0000000 --- a/m2d/converters/event-to-message.js +++ /dev/null @@ -1,529 +0,0 @@ -// @ts-check - -const Ty = require("../../types") -const DiscordTypes = require("discord-api-types/v10") -const chunk = require("chunk-text") -const TurndownService = require("turndown") -const assert = require("assert").strict -const entities = require("entities") - -const passthrough = require("../../passthrough") -const {sync, db, discord, select, from} = passthrough -/** @type {import("../../matrix/file")} */ -const file = sync.require("../../matrix/file") -/** @type {import("../converters/utils")} */ -const utils = sync.require("../converters/utils") -/** @type {import("./emoji-sheet")} */ -const emojiSheet = sync.require("./emoji-sheet") - -/** @type {[RegExp, string][]} */ -const markdownEscapes = [ - [/\\/g, '\\\\'], - [/\*/g, '\\*'], - [/^-/g, '\\-'], - [/^\+ /g, '\\+ '], - [/^(=+)/g, '\\$1'], - [/^(#{1,6}) /g, '\\$1 '], - [/`/g, '\\`'], - [/^~~~/g, '\\~~~'], - [/\[/g, '\\['], - [/\]/g, '\\]'], - [/^>/g, '\\>'], - [/_/g, '\\_'], - [/^(\d+)\. /g, '$1\\. '] - ] - -const turndownService = new TurndownService({ - hr: "----", - headingStyle: "atx", - preformattedCode: true, - codeBlockStyle: "fenced", -}) - -/** - * Markdown characters in the HTML content need to be escaped, though take care not to escape the middle of bare links - * @param {string} string - */ -// @ts-ignore bad type from turndown -turndownService.escape = function (string) { - const escapedWords = string.split(" ").map(word => { - if (word.match(/^https?:\/\//)) { - return word - } else { - return markdownEscapes.reduce(function (accumulator, escape) { - return accumulator.replace(escape[0], escape[1]) - }, word) - } - }) - return escapedWords.join(" ") -} - -turndownService.remove("mx-reply") - -turndownService.addRule("strikethrough", { - filter: ["del", "s"], - replacement: function (content) { - return "~~" + content + "~~" - } -}) - -turndownService.addRule("underline", { - filter: ["u"], - replacement: function (content) { - return "__" + content + "__" - } -}) - -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.hasAttribute("data-mx-spoiler") - }, - - replacement: function (content, node) { - return "||" + content + "||" - } -}) - -turndownService.addRule("inlineLink", { - filter: function (node, options) { - return ( - node.nodeName === "A" && - node.getAttribute("href") - ) - }, - - replacement: function (content, node) { - if (node.getAttribute("data-user-id")) return `<@${node.getAttribute("data-user-id")}>` - if (node.getAttribute("data-channel-id")) return `<#${node.getAttribute("data-channel-id")}>` - const href = node.getAttribute("href") - let brackets = ["", ""] - if (href.startsWith("https://matrix.to")) brackets = ["<", ">"] - return "[" + content + "](" + brackets[0] + href + brackets[1] + ")" - } -}) - -/** @type {string[]} SPRITE SHEET EMOJIS FEATURE: mxc urls for the currently processing message */ -let endOfMessageEmojis = [] -turndownService.addRule("emoji", { - filter: function (node, options) { - if (node.nodeName !== "IMG" || !node.hasAttribute("data-mx-emoticon") || !node.getAttribute("src") || !node.getAttribute("title")) return false - return true - }, - - replacement: function (content, node) { - const mxcUrl = node.getAttribute("src") - // Get the known emoji from the database. (We may not be able to actually use this if it was from another server.) - const row = select("emoji", ["emoji_id", "name", "animated"], {mxc_url: mxcUrl}).get() - // Also guess a suitable emoji based on the ID (if available) or name - let guess = null - const guessedName = node.getAttribute("title").replace(/^:|:$/g, "") - for (const guild of discord.guilds.values()) { - /** @type {{name: string, id: string, animated: number}[]} */ - // @ts-ignore - const emojis = guild.emojis - const match = emojis.find(e => e.id === row?.emoji_id) || emojis.find(e => e.name === guessedName) || emojis.find(e => e.name?.toLowerCase() === guessedName.toLowerCase()) - if (match) { - guess = match - break - } - } - if (guess) { - // We know an emoji, and we can use it - const animatedChar = guess.animated ? "a" : "" - return `<${animatedChar}:${guess.name}:${guess.id}>` - } else if (endOfMessageEmojis.includes(mxcUrl)) { - // We can't locate or use a suitable emoji. After control returns, it will rewind over this, delete this section, and upload the emojis as a sprite sheet. - return `<::>` - } else { - // We prefer not to upload this as a sprite sheet because the emoji is not at the end of the message, it is in the middle. - return `[${node.getAttribute("title")}](${utils.getPublicUrlForMxc(mxcUrl)})` - } - } -}) - -turndownService.addRule("fencedCodeBlock", { - filter: function (node, options) { - return ( - options.codeBlockStyle === "fenced" && - node.nodeName === "PRE" && - node.firstChild && - node.firstChild.nodeName === "CODE" - ) - }, - replacement: function (content, node, options) { - const className = node.firstChild.getAttribute("class") || "" - const language = (className.match(/language-(\S+)/) || [null, ""])[1] - const code = node.firstChild - const visibleCode = code.childNodes.map(c => c.nodeName === "BR" ? "\n" : c.textContent).join("").replace(/\n*$/g, "") - - var fence = "```" - - return ( - fence + language + "\n" + - visibleCode + - "\n" + fence - ) - } -}) - -/** - * @param {string} roomID - * @param {string} mxid - * @returns {Promise<{displayname?: string?, avatar_url?: string?}>} - */ -async function getMemberFromCacheOrHomeserver(roomID, mxid, api) { - const row = select("member_cache", ["displayname", "avatar_url"], {room_id: roomID, mxid}).get() - if (row) return row - return api.getStateEvent(roomID, "m.room.member", mxid).then(event => { - db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(roomID, mxid, event?.displayname || null, event?.avatar_url || null) - return event - }).catch(() => { - return {displayname: null, avatar_url: null} - }) -} - -/** - * Splits a display name into one chunk containing <=80 characters, and another chunk containing the rest of the characters. Splits on - * whitespace if possible. - * These chunks, respectively, go in the display name, and at the top of the message. - * If the second part isn't empty, it'll also contain boldening markdown and a line break at the end, so that regardless of its value it - * can be prepended to the message content as-is. - * @summary Splits too-long Matrix names into a display name chunk and a message content chunk. - * @param {string} displayName - The Matrix side display name to chop up. - * @returns {[string, string]} [shortened display name, display name runoff] - */ -function splitDisplayName(displayName) { - /** @type {string[]} */ - let displayNameChunks = chunk(displayName, 80) - - if (displayNameChunks.length === 1) { - return [displayName, ""] - } else { - const displayNamePreRunoff = displayNameChunks[0] - // displayNameRunoff is a slice of the original rather than a concatenation of the rest of the chunks in order to preserve whatever whitespace it was broken on. - const displayNameRunoff = `**${displayName.slice(displayNamePreRunoff.length + 1)}**\n` - - return [displayNamePreRunoff, displayNameRunoff] - } -} - -/** - * 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 {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} pendingFiles - */ -async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles) { - if (!content.includes("<::>")) return content // No unknown emojis, nothing to do - // Remove known and unknown emojis from the end of the message - const r = /\s*$/ - while (content.match(r)) { - content = content.replace(r, "") - } - // Create a sprite sheet of known and unknown emojis from the end of the message - const buffer = await emojiSheet.compositeMatrixEmojis(endOfMessageEmojis) - // Attach it - const name = "emojis.png" - attachments.push({id: "0", name}) - pendingFiles.push({name, buffer}) - return content -} - -/** - * @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 - * @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API - */ -async function eventToMessage(event, guild, di) { - /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer}[]})[]} */ - let messages = [] - - let displayName = event.sender - let avatarURL = undefined - /** @type {string[]} */ - let messageIDsToEdit = [] - let replyLine = "" - // Extract a basic display name from the sender - const match = event.sender.match(/^@(.*?):/) - if (match) displayName = match[1] - // Try to extract an accurate display name and avatar URL from the member event - const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api) - if (member.displayname) displayName = member.displayname - if (member.avatar_url) avatarURL = utils.getPublicUrlForMxc(member.avatar_url) || undefined - // If the display name is too long to be put into the webhook (80 characters is the maximum), - // put the excess characters into displayNameRunoff, later to be put at the top of the message - let [displayNameShortened, displayNameRunoff] = splitDisplayName(displayName) - // If the message type is m.emote, the full name is already included at the start of the message, so remove any runoff - if (event.type === "m.room.message" && event.content.msgtype === "m.emote") { - displayNameRunoff = "" - } - - let content = event.content.body // ultimate fallback - const attachments = [] - /** @type {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */ - const pendingFiles = [] - - // Convert content depending on what the message is - if (event.type === "m.room.message" && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote")) { - // Handling edits. If the edit was an edit of a reply, edits do not include the reply reference, so we need to fetch up to 2 more events. - // this event ---is an edit of--> original event ---is a reply to--> past event - await (async () => { - if (!event.content["m.new_content"]) return - const relatesTo = event.content["m.relates_to"] - if (!relatesTo) return - // Check if we have a pointer to what was edited - const relType = relatesTo.rel_type - if (relType !== "m.replace") return - const originalEventId = relatesTo.event_id - if (!originalEventId) return - messageIDsToEdit = select("event_message", "message_id", {event_id: originalEventId}, "ORDER BY part").pluck().all() - if (!messageIDsToEdit.length) return - - // Ok, it's an edit. - event.content = event.content["m.new_content"] - - // Is it editing a reply? We need special handling if it is. - // Get the original event, then check if it was a reply - const originalEvent = await di.api.getEvent(event.room_id, originalEventId) - if (!originalEvent) return - const repliedToEventId = originalEvent.content["m.relates_to"]?.["m.in_reply_to"]?.event_id - if (!repliedToEventId) return - - // After all that, it's an edit of a reply. - // We'll be sneaky and prepare the message data so that the next steps can handle it just like original messages. - Object.assign(event.content, { - "m.relates_to": { - "m.in_reply_to": { - event_id: repliedToEventId - } - } - }) - })() - - // Handling replies. We'll look up the data of the replied-to event from the Matrix homeserver. - // Note that an element is not guaranteed because this might be m.new_content. - await (async () => { - const repliedToEventId = event.content["m.relates_to"]?.["m.in_reply_to"]?.event_id - if (!repliedToEventId) return - let repliedToEvent = await di.api.getEvent(event.room_id, repliedToEventId) - if (!repliedToEvent) 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")}>` - const row = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: repliedToEventId}).and("ORDER BY part").get() - if (row) { - replyLine += `https://discord.com/channels/${guild.id}/${row.channel_id}/${row.message_id} ` - } - const sender = repliedToEvent.sender - const authorID = select("sim", "user_id", {mxid: repliedToEvent.sender}).pluck().get() - if (authorID) { - replyLine += `<@${authorID}>` - } else { - let senderName = select("member_cache", "displayname", {mxid: repliedToEvent.sender}).pluck().get() - if (!senderName) senderName = sender.match(/@([^:]*)/)?.[1] || sender - replyLine += `Ⓜ️**${senderName}**` - } - // If the event has been edited, the homeserver will include the relation in `unsigned`. - if (repliedToEvent.unsigned?.["m.relations"]?.["m.replace"]?.content?.["m.new_content"]) { - repliedToEvent = repliedToEvent.unsigned["m.relations"]["m.replace"] // Note: this changes which event_id is in repliedToEvent. - repliedToEvent.content = repliedToEvent.content["m.new_content"] - } - let contentPreview - const fileReplyContentAlternative = - ( repliedToEvent.content.msgtype === "m.image" ? "🖼️" - : repliedToEvent.content.msgtype === "m.video" ? "🎞️" - : repliedToEvent.content.msgtype === "m.audio" ? "🎶" - : repliedToEvent.content.msgtype === "m.file" ? "📄" - : null) - if (fileReplyContentAlternative) { - contentPreview = " " + fileReplyContentAlternative - } else { - const repliedToContent = repliedToEvent.content.formatted_body || repliedToEvent.content.body - const contentPreviewChunks = chunk( - entities.decodeHTML5Strict( // Remove entities like & " - repliedToContent.replace(/.*<\/mx-reply>/, "") // Remove everything before replies, so just use the actual message body - .replace(/^\s*
.*?<\/blockquote>(.....)/s, "$1") // If the message starts with a blockquote, don't count it and use the message body afterwards - .replace(/(?:\n|
)+/g, " ") // Should all be on one line - .replace(/]*data-mx-spoiler\b[^>]*>.*?<\/span>/g, "[spoiler]") // Good enough method of removing spoiler content. (I don't want to break out the HTML parser unless I have to.) - .replace(/<[^>]+>/g, "") // Completely strip all HTML tags and formatting. - ), 50) - contentPreview = ":\n> " - contentPreview += contentPreviewChunks.length > 1 ? contentPreviewChunks[0] + "..." : contentPreviewChunks[0] - } - replyLine = `> ${replyLine}${contentPreview}\n` - })() - - if (event.content.format === "org.matrix.custom.html" && event.content.formatted_body) { - let input = event.content.formatted_body - if (event.content.msgtype === "m.emote") { - input = `* ${displayName} ${input}` - } - - // Handling mentions of Discord users - input = input.replace(/("https:\/\/matrix.to\/#\/(@[^"]+)")>/g, (whole, attributeValue, mxid) => { - if (!utils.eventSenderIsFromDiscord(mxid)) return whole - const userID = select("sim", "user_id", {mxid: mxid}).pluck().get() - if (!userID) return whole - return `${attributeValue} data-user-id="${userID}">` - }) - - // Handling mentions of Discord rooms - input = input.replace(/("https:\/\/matrix.to\/#\/(![^"]+)")>/g, (whole, attributeValue, roomID) => { - const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get() - if (!channelID) return whole - return `${attributeValue} data-channel-id="${channelID}">` - }) - - // Element adds a bunch of
before
but doesn't render them. I can't figure out how this even works in the browser, so let's just delete those. - input = input.replace(/(?:\n|
\s*)*<\/blockquote>/g, "") - - // The matrix spec hasn't decided whether \n counts as a newline or not, but I'm going to count it, because if it's in the data it's there for a reason. - // But I should not count it if it's between block elements. - input = input.replace(/(<\/?([^ >]+)[^>]*>)?\n(<\/?([^ >]+)[^>]*>)?/g, (whole, beforeContext, beforeTag, afterContext, afterTag) => { - // console.error(beforeContext, beforeTag, afterContext, afterTag) - if (typeof beforeTag !== "string" && typeof afterTag !== "string") { - return "
" - } - beforeContext = beforeContext || "" - beforeTag = beforeTag || "" - afterContext = afterContext || "" - afterTag = afterTag || "" - if (!utils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !utils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) { - return beforeContext + "
" + afterContext - } else { - return whole - } - }) - - // Note: Element's renderers on Web and Android currently collapse whitespace, like the browser does. Turndown also collapses whitespace which is good for me. - // If later I'm using a client that doesn't collapse whitespace and I want turndown to follow suit, uncomment the following line of code, and it Just Works: - // input = input.replace(/ /g, " ") - // There is also a corresponding test to uncomment, named "event2message: whitespace is retained" - - // SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet. - // First we need to determine which emojis are at the end. - endOfMessageEmojis = [] - let match - let last = input.length - while ((match = input.slice(0, last).match(/]*>\s*$/))) { - if (!match[0].includes("data-mx-emoticon")) break - const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/) - if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1]) - if (typeof match.index !== "number") break - last = match.index - } - - // @ts-ignore bad type from turndown - content = turndownService.turndown(input) - - // It's designed for commonmark, we need to replace the space-space-newline with just newline - content = content.replace(/ \n/g, "\n") - - // SPRITE SHEET EMOJIS FEATURE: - content = await uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles) - } else { - // Looks like we're using the plaintext body! - content = event.content.body - - if (event.content.msgtype === "m.emote") { - content = `* ${displayName} ${content}` - } - - // Markdown needs to be escaped, though take care not to escape the middle of links - // @ts-ignore bad type from turndown - content = turndownService.escape(content) - } - } else if (event.type === "m.room.message" && (event.content.msgtype === "m.file" || event.content.msgtype === "m.video" || event.content.msgtype === "m.audio" || event.content.msgtype === "m.image")) { - content = "" - const filename = event.content.body - if ("url" in event.content) { - // Unencrypted - const url = utils.getPublicUrlForMxc(event.content.url) - assert(url) - attachments.push({id: "0", filename}) - pendingFiles.push({name: filename, url}) - } else { - // Encrypted - const url = utils.getPublicUrlForMxc(event.content.file.url) - assert(url) - assert.equal(event.content.file.key.alg, "A256CTR") - attachments.push({id: "0", filename}) - pendingFiles.push({name: filename, url, key: event.content.file.key.k, iv: event.content.file.iv}) - } - } else if (event.type === "m.sticker") { - content = "" - const url = utils.getPublicUrlForMxc(event.content.url) - assert(url) - 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 fetch(url, {method: "HEAD"}) - mimetype = res.headers.get("content-type") || "image/webp" - } - filename += "." + mimetype.split("/")[1] - } - attachments.push({id: "0", filename}) - pendingFiles.push({name: filename, url}) - } - - content = displayNameRunoff + replyLine + content - - // Split into 2000 character chunks - const chunks = chunk(content, 2000) - messages = messages.concat(chunks.map(content => ({ - content, - username: displayNameShortened, - avatar_url: avatarURL - }))) - - if (attachments.length) { - // If content is empty (should be the case when uploading a file) then chunk-text will create 0 messages. - // There needs to be a message to add attachments to. - if (!messages.length) messages.push({ - content, - username: displayNameShortened, - avatar_url: avatarURL - }) - messages[0].attachments = attachments - // @ts-ignore these will be converted to real files when the message is about to be sent - messages[0].pendingFiles = pendingFiles - } - - const messagesToEdit = [] - const messagesToSend = [] - for (let i = 0; i < messages.length; i++) { - const next = messageIDsToEdit[0] - if (next) { - messagesToEdit.push({id: next, message: messages[i]}) - messageIDsToEdit.shift() - } else { - messagesToSend.push(messages[i]) - } - } - - // Ensure there is code coverage for adding, editing, and deleting - if (messagesToSend.length) void 0 - if (messagesToEdit.length) void 0 - if (messageIDsToEdit.length) void 0 - - return { - messagesToEdit, - messagesToSend, - messagesToDelete: messageIDsToEdit - } -} - -module.exports.eventToMessage = eventToMessage diff --git a/m2d/converters/event-to-message.test.js b/m2d/converters/event-to-message.test.js deleted file mode 100644 index 074da1d..0000000 --- a/m2d/converters/event-to-message.test.js +++ /dev/null @@ -1,2070 +0,0 @@ -const assert = require("assert").strict -const {test} = require("supertape") -const {eventToMessage} = require("./event-to-message") -const data = require("../../test/data") -const {MatrixServerError} = require("../../matrix/mreq") -const {db, select} = require("../../passthrough") - -/* c8 ignore next 7 */ -function slow() { - if (process.argv.includes("--slow")) { - return test - } else { - return test.skip - } -} - -/** - * @param {string} roomID - * @param {string} eventID - * @returns {(roomID: string, eventID: string) => Promise>} - */ -function mockGetEvent(t, roomID_in, eventID_in, outer) { - return async function(roomID, eventID) { - t.equal(roomID, roomID_in) - t.equal(eventID, eventID_in) - return new Promise(resolve => { - setTimeout(() => { - resolve({ - event_id: eventID_in, - room_id: roomID_in, - origin_server_ts: 1680000000000, - unsigned: { - age: 2245, - transaction_id: "$local.whatever" - }, - ...outer - }) - }) - }) - } -} - -function sameFirstContentAndWhitespace(t, a, b) { - const a2 = JSON.stringify(a.messagesToSend[0].content) - const b2 = JSON.stringify(b.messagesToSend[0].content) - t.equal(a2, b2) -} - -test("event2message: body is used when there is no formatted_body", async t => { - t.deepEqual( - await eventToMessage({ - content: { - body: "testing plaintext", - msgtype: "m.text" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "testing plaintext", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: any markdown in body is escaped, except strikethrough", async t => { - t.deepEqual( - await eventToMessage({ - content: { - body: "testing **special** ~~things~~ which _should_ *not* `trigger` @any , except strikethrough", - msgtype: "m.text" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "testing \\*\\*special\\*\\* ~~things~~ which \\_should\\_ \\*not\\* \\`trigger\\` @any , except strikethrough", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: links in formatted body are not broken", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "kyuugryphon I wonder what the midjourney text description of this photo is https://upload.wikimedia.org/wikipedia/commons/f/f3/After_gay_pride%2C_rainbow_flags_flying_along_Beach_Street_%2814853144744%29.jpg", - format: "org.matrix.custom.html", - formatted_body: "kyuugryphon I wonder what the midjourney text description of this photo is https://upload.wikimedia.org/wikipedia/commons/f/f3/After_gay_pride%2C_rainbow_flags_flying_along_Beach_Street_%2814853144744%29.jpg" - }, - origin_server_ts: 1693739630700, - unsigned: { - age: 39, - transaction_id: "m1693739630587.160" - }, - event_id: "$zANQGOdnHKZj48lrajojsejH86KNYST26imgb2Sw1Jg", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "<@111604486476181504> I wonder what the midjourney text description of this photo is https://upload.wikimedia.org/wikipedia/commons/f/f3/After_gay_pride%2C_rainbow_flags_flying_along_Beach_Street_%2814853144744%29.jpg", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: links in plaintext body are not broken", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "I wonder what the midjourney text description of this photo is https://upload.wikimedia.org/wikipedia/commons/f/f3/After_gay_pride%2C_rainbow_flags_flying_along_Beach_Street_%2814853144744%29.jpg", - }, - origin_server_ts: 1693739630700, - unsigned: { - age: 39, - transaction_id: "m1693739630587.160" - }, - event_id: "$zANQGOdnHKZj48lrajojsejH86KNYST26imgb2Sw1Jg", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "I wonder what the midjourney text description of this photo is https://upload.wikimedia.org/wikipedia/commons/f/f3/After_gay_pride%2C_rainbow_flags_flying_along_Beach_Street_%2814853144744%29.jpg", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: basic html is converted to markdown", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: "this is a test of formatting" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "this **is** a _**test** __of___ ~~_formatting_~~", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: spoilers work", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: `this is a test of spoilers` - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "this **is** a ||_test_|| of ||spoilers||", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: markdown syntax is escaped", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: "this **is** an extreme \\*test\\* of" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "this \\*\\*is\\*\\* an **_extreme_** \\\\\\*test\\\\\\* of", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: html lines are bridged correctly", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: "

paragraph one
line two
line three

paragraph two\nline two\nline three\n\nparagraph three

paragraph four\nline two
line three\nline four

paragraph five" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "paragraph one\nline _two_\nline three\n\nparagraph two\nline _two_\nline three\n\nparagraph three\n\nparagraph four\nline two\nline three\nline four\n\nparagraph five", - avatar_url: undefined - }] - } - ) -}) - -/*test("event2message: whitespace is retained", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: "line one: test test
line two: test test
line three: test test
line four: test test
line five" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "line one: test test\nline two: **test** **test**\nline three: **test test**\nline four: test test\n line five", - avatar_url: undefined - }] - } - ) -})*/ - -test("event2message: whitespace is collapsed", async t => { - sameFirstContentAndWhitespace( - t, - await eventToMessage({ - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: "line one: test test
line two: test test
line three: test test
line four: test test
line five" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "line one: test test\nline two: **test** **test**\nline three: **test test**\nline four: test test\nline five", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: lists are bridged correctly", async t => { - sameFirstContentAndWhitespace( - t, - await eventToMessage({ - "type": "m.room.message", - "sender": "@cadence:cadence.moe", - "content": { - "msgtype": "m.text", - "body": "* line one\n* line two\n* line three\n * nested one\n * nested two\n* line four", - "format": "org.matrix.custom.html", - "formatted_body": "
    \n
  • line one
  • \n
  • line two
  • \n
  • line three\n
      \n
    • nested one
    • \n
    • nested two
    • \n
    \n
  • \n
  • line four
  • \n
\n" - }, - "origin_server_ts": 1692967314062, - "unsigned": { - "age": 112, - "transaction_id": "m1692967313951.441" - }, - "event_id": "$l-xQPY5vNJo3SNxU9d8aOWNVD1glMslMyrp4M_JEF70", - "room_id": "!BpMdOUkWWhFxmTrENV:cadence.moe" - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "* line one\n* line two\n* line three\n * nested one\n * nested two\n* line four", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: long messages are split", async t => { - t.deepEqual( - await eventToMessage({ - content: { - body: ("a".repeat(130) + " ").repeat(19), - msgtype: "m.text" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: (("a".repeat(130) + " ").repeat(15)).slice(0, -1), - avatar_url: undefined - }, { - username: "cadence [they]", - content: (("a".repeat(130) + " ").repeat(4)).slice(0, -1), - avatar_url: undefined - }] - } - ) -}) - -test("event2message: code blocks work", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: "

preceding

\n
code block\n
\n

following code is inline

\n" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "preceding\n\n```\ncode block\n```\n\nfollowing `code` is inline", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: code block contents are formatted correctly and not escaped", 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": "
input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n_input_ = input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n
\n

input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,

\n" - }, - "origin_server_ts": 1693031482275, - "unsigned": { - "age": 99, - "transaction_id": "m1693031482146.511" - }, - "event_id": "$pGkWQuGVmrPNByrFELxhzI6MCBgJecr5I2J3z88Gc2s", - "room_id": "!BpMdOUkWWhFxmTrENV:cadence.moe" - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "```\ninput = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n_input_ = input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,\n```\n\n`input = input.replace(/(<\\/?([^ >]+)[^>]*>)?\\n(<\\/?([^ >]+)[^>]*>)?/g,`", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: quotes have an appropriate amount of whitespace", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: "
Chancellor of Germany Angela Merkel, on March 17, 2017: they did not shake hands



🤨" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "> Chancellor of Germany Angela Merkel, on March 17, 2017: they did not shake hands\n🤨", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: m.emote plaintext works", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.emote", - body: "tests an m.emote message" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "\\* cadence \\[they\\] tests an m.emote message", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: m.emote markdown syntax is escaped", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.emote", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: "shows you **her** extreme \\*test\\* of" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "\\* cadence \\[they\\] shows you \\*\\*her\\*\\* **_extreme_** \\\\\\*test\\\\\\* of", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: rich reply to a sim user", async t => { - t.deepEqual( - await eventToMessage({ - "type": "m.room.message", - "sender": "@cadence:cadence.moe", - "content": { - "msgtype": "m.text", - "body": "> <@_ooye_kyuugryphon:cadence.moe> Slow news day.\n\nTesting this reply, ignore", - "format": "org.matrix.custom.html", - "formatted_body": "
In reply to @_ooye_kyuugryphon:cadence.moe
Slow news day.
Testing this reply, ignore", - "m.relates_to": { - "m.in_reply_to": { - "event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04" - } - } - }, - "origin_server_ts": 1693029683016, - "unsigned": { - "age": 91, - "transaction_id": "m1693029682894.510" - }, - "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", - "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, { - api: { - getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", { - type: "m.room.message", - content: { - msgtype: "m.text", - body: "Slow news day." - }, - sender: "@_ooye_kyuugryphon:cadence.moe" - }) - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" - + "\n> Slow news day." - + "\nTesting this reply, ignore", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" - }] - } - ) -}) - -test("event2message: rich reply to an already-edited message will quote the new message content", async t => { - t.deepEqual( - await eventToMessage({ - "type": "m.room.message", - "sender": "@cadence:cadence.moe", - "content": { - "msgtype": "m.text", - "body": "> <@_ooye_kyuugryphon:cadence.moe> this is the new content. heya!\n\nhiiiii....", - "format": "org.matrix.custom.html", - "formatted_body": "
In reply to @_ooye_kyuugryphon:cadence.moe
this is the new content. heya!
hiiiii....", - "m.relates_to": { - "m.in_reply_to": { - "event_id": "$DSQvWxOBB2DYaei6b83-fb33dQGYt5LJd_s8Nl2a43Q" - } - } - }, - "origin_server_ts": 1693029683016, - "unsigned": { - "age": 91, - "transaction_id": "m1693029682894.510" - }, - "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", - "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, { - api: { - getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$DSQvWxOBB2DYaei6b83-fb33dQGYt5LJd_s8Nl2a43Q", { - type: "m.room.message", - room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe", - sender: "@_ooye_kyuugryphon:cadence.moe", - content: { - "m.mentions": {}, - msgtype: "m.text", - body: "this is the old content. don't use this!" - }, - unsigned: { - "m.relations": { - "m.replace": { - type: "m.room.message", - room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe", - sender: "@_ooye_kyuugryphon:cadence.moe", - content: { - "m.mentions": {}, - msgtype: "m.text", - body: "* this is the new content. heya!", - "m.new_content": { - "m.mentions": {}, - msgtype: "m.text", - body: "this is the new content. heya!" - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: "$DSQvWxOBB2DYaei6b83-fb33dQGYt5LJd_s8Nl2a43Q" - } - }, - event_id: "$JOrl8ycWpo7NIAxZ4u-VJmANVrZFBF41LXyp30y8VvU", - user_id: "@_ooye_kyuugryphon:cadence.moe", - } - } - } - }) - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647><@111604486476181504>:" - + "\n> this is the new content. heya!" - + "\nhiiiii....", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" - }] - } - ) -}) - -test("event2message: should avoid using blockquote contents as reply preview in rich reply to a sim user", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "> <@_ooye_kyuugryphon:cadence.moe> > well, you said this, so...\n> \n> that can't be true! there's no way :o\n\nI agree!", - format: "org.matrix.custom.html", - formatted_body: "
In reply to @_ooye_kyuugryphon:cadence.moe
well, you said this, so...

that can't be true! there's no way :o
I agree!", - "m.relates_to": { - "m.in_reply_to": { - event_id: "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04" - } - } - }, - event_id: "$BpGx8_vqHyN6UQDARPDU51ftrlRBhleutRSgpAJJ--g", - room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, { - api: { - getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", { - "type": "m.room.message", - "sender": "@_ooye_kyuugryphon:cadence.moe", - "content": { - "m.mentions": {}, - "msgtype": "m.text", - "body": "> well, you said this, so...\n\nthat can't be true! there's no way :o", - "format": "org.matrix.custom.html", - "formatted_body": "
well, you said this, so...

that can't be true! there's no way :o" - } - }) - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" - + "\n> that can't be true! there's no way :o" - + "\nI agree!", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" - }] - } - ) -}) - -test("event2message: should include a reply preview when message ends with a blockquote", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "> <@_ooye_cookie:cadence.moe> https://tootsuite.net/Warp-Gate2.gif\n> tanget: @ monster spawner\n> \n> **https://tootsuite.net/Warp-Gate2.gif**\n\naichmophobia", - format: "org.matrix.custom.html", - formatted_body: "
In reply to @_ooye_cookie:cadence.moe
https://tootsuite.net/Warp-Gate2.gif
tanget: @ monster spawner
https://tootsuite.net/Warp-Gate2.gif
aichmophobia", - "m.relates_to": { - "m.in_reply_to": { - event_id: "$uXM2I6w-XMtim14-OSZ_8Z2uQ6MDAZLT37eYIiEU6KQ" - } - } - }, - event_id: "$n6sg1X9rLeMzCYufJTRvaLzFeLQ-oEXjCWkHtRxcem4", - room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, { - api: { - getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$uXM2I6w-XMtim14-OSZ_8Z2uQ6MDAZLT37eYIiEU6KQ", { - type: 'm.room.message', - sender: '@_ooye_cookie:cadence.moe', - content: { - 'm.mentions': {}, - msgtype: 'm.text', - body: 'https://tootsuite.net/Warp-Gate2.gif\n' + - '\n' + - '**https://tootsuite.net/Warp-Gate2.gif**', - format: 'org.matrix.custom.html', - formatted_body: 'https://tootsuite.net/Warp-Gate2.gif
https://tootsuite.net/Warp-Gate2.gif
' - }, - unsigned: { - 'm.relations': { - 'm.replace': { - type: 'm.room.message', - room_id: '!fGgIymcYWOqjbSRUdV:cadence.moe', - sender: '@_ooye_cookie:cadence.moe', - content: { - 'm.mentions': {}, - msgtype: 'm.text', - body: '* https://tootsuite.net/Warp-Gate2.gif\n' + - 'tanget: @ monster spawner\n' + - '\n' + - '**https://tootsuite.net/Warp-Gate2.gif**', - format: 'org.matrix.custom.html', - formatted_body: '* https://tootsuite.net/Warp-Gate2.gif
tanget: @ monster spawner
https://tootsuite.net/Warp-Gate2.gif
', - 'm.new_content': { - 'm.mentions': {}, - msgtype: 'm.text', - body: 'https://tootsuite.net/Warp-Gate2.gif\n' + - 'tanget: @ monster spawner\n' + - '\n' + - '**https://tootsuite.net/Warp-Gate2.gif**', - format: 'org.matrix.custom.html', - formatted_body: 'https://tootsuite.net/Warp-Gate2.gif
tanget: @ monster spawner
https://tootsuite.net/Warp-Gate2.gif
' - }, - 'm.relates_to': { - rel_type: 'm.replace', - event_id: '$uXM2I6w-XMtim14-OSZ_8Z2uQ6MDAZLT37eYIiEU6KQ' - } - }, - event_id: '$onCj1MucuYz6-dFr30jcnnjSEDq50ouyEbRId1wtAa8', - user_id: '@_ooye_cookie:cadence.moe', - } - } - }, - user_id: '@_ooye_cookie:cadence.moe', - }) - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>Ⓜ️**_ooye_cookie**:" - + "\n> https://tootsuite.net/Warp-Gate2.gif tanget: @..." - + "\naichmophobia", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" - }] - } - ) -}) - -test("event2message: should include a reply preview when replying to a description-only bot embed", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "> <@_ooye_amanda:cadence.moe> > It looks like this queue has ended.\n\nso you're saying on matrix side I would have to edit ^this^ to add \"Timed out\" before the blockquote?", - format: "org.matrix.custom.html", - formatted_body: "
In reply to @_ooye_amanda:cadence.moe
It looks like this queue has ended.
so you're saying on matrix side I would have to edit ^this^ to add "Timed out" before the blockquote?", - "m.relates_to": { - "m.in_reply_to": { - event_id: "$zJFjTvNn1w_YqpR4o4ISKUFisNRgZcu1KSMI_LADPVQ" - } - } - }, - event_id: "$qCOlszCawu5hlnF2a2PGyXeGGvtoNJdXyRAEaTF0waA", - room_id: "!CzvdIdUQXgUjDVKxeU:cadence.moe" - }, data.guild.general, { - api: { - getEvent: mockGetEvent(t, "!CzvdIdUQXgUjDVKxeU:cadence.moe", "$zJFjTvNn1w_YqpR4o4ISKUFisNRgZcu1KSMI_LADPVQ", { - type: "m.room.message", - room_id: "!edUxjVdzgUvXDUIQCK:cadence.moe", - sender: "@_ooye_amanda:cadence.moe", - content: { - "m.mentions": {}, - msgtype: "m.notice", - body: "> Now Playing: [**LOADING**](https://amanda.moe)\n" + - "> \n" + - "> `[​====[LOADING]=====]`", - format: "org.matrix.custom.html", - formatted_body: '
Now Playing: LOADING

[​====[LOADING]=====]
' - }, - unsigned: { - "m.relations": { - "m.replace": { - type: "m.room.message", - room_id: "!edUxjVdzgUvXDUIQCK:cadence.moe", - sender: "@_ooye_amanda:cadence.moe", - content: { - "m.mentions": {}, - msgtype: "m.notice", - body: "* > It looks like this queue has ended.", - format: "org.matrix.custom.html", - formatted_body: "*
It looks like this queue has ended.
", - "m.new_content": { - "m.mentions": {}, - msgtype: "m.notice", - body: "> It looks like this queue has ended.", - format: "org.matrix.custom.html", - formatted_body: "
It looks like this queue has ended.
" - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: "$zJFjTvNn1w_YqpR4o4ISKUFisNRgZcu1KSMI_LADPVQ" - } - }, - event_id: "$nrLF310vALFIXPNk6MEIy0lYiGXi210Ok0DATSaF5jQ", - user_id: "@_ooye_amanda:cadence.moe", - } - }, - user_id: "@_ooye_amanda:cadence.moe", - } - }) - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/497161350934560778/1162625810109317170 <@1109360903096369153>:" - + "\n> It looks like this queue has ended." - + `\nso you're saying on matrix side I would have to edit ^this^ to add "Timed out" before the blockquote?`, - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" - }] - } - ) -}) - -test("event2message: entities are not escaped in main message or reply preview", async t => { - // Intended result: Testing? in italics, followed by the sequence "':.`[]&things - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "> <@cadence:cadence.moe> _Testing?_ \"':.`[]&things\n\n_Testing?_ \"':.`[]&things", - format: "org.matrix.custom.html", - formatted_body: "
In reply to @cadence:cadence.moe
Testing? \"':.`[]&things
Testing? "':.`[]&things", - "m.relates_to": { - "m.in_reply_to": { - event_id: "$yIWjZPi6Xk56fBxJwqV4ANs_hYLjnWI2cNKbZ2zwk60" - } - } - }, - event_id: "$2I7odT9okTdpwDcqOjkJb_A3utdO4V8Cp3LK6-Rvwcs", - room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, { - api: { - getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$yIWjZPi6Xk56fBxJwqV4ANs_hYLjnWI2cNKbZ2zwk60", { - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - "msgtype": "m.text", - "body": "_Testing?_ \"':.`[]&things", - "format": "org.matrix.custom.html", - "formatted_body": "Testing? "':.`[]&things" - }, - event_id: "$yIWjZPi6Xk56fBxJwqV4ANs_hYLjnWI2cNKbZ2zwk60", - room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe" - }) - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>Ⓜ️**cadence [they]**:" - + "\n> Testing? \"':.`[]&things" - + "\n_Testing?_ \"':.\\`\\[\\]&things", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" - }] - } - ) -}) - -test("event2message: editing a rich reply to a sim user", async t => { - const eventsFetched = [] - t.deepEqual( - await eventToMessage({ - "type": "m.room.message", - "sender": "@cadence:cadence.moe", - "content": { - "msgtype": "m.text", - "body": "> <@_ooye_kyuugryphon:cadence.moe> Slow news day.\n\n * Editing this reply, which is also a test", - "m.new_content": { - "msgtype": "m.text", - "body": "Editing this reply, which is also a test", - "format": "org.matrix.custom.html", - "formatted_body": "Editing this reply, which is also a test" - }, - "format": "org.matrix.custom.html", - "formatted_body": "
In reply to @_ooye_kyuugryphon:cadence.moe
Slow news day.
* Editing this reply, which is also a test", - "m.relates_to": { - "rel_type": "m.replace", - "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8" - } - }, - "origin_server_ts": 1693222931237, - "unsigned": { - "age": 44, - "transaction_id": "m1693222931143.837" - }, - "event_id": "$XEgssz13q-a7NLO7UZO2Oepq7tSiDBD7YRfr7Xu_QiA", - "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, { - api: { - getEvent: (roomID, eventID) => { - assert.ok(eventID === "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04" || eventID === "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8") - if (eventID === "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04") { - eventsFetched.push("past") - return mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", { - type: "m.room.message", - content: { - msgtype: "m.text", - body: "Slow news day." - }, - sender: "@_ooye_kyuugryphon:cadence.moe" - })(roomID, eventID) - } else if (eventID === "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8") { - eventsFetched.push("original") - return mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", { - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "> <@_ooye_kyuugryphon:cadence.moe> Slow news day.\n\nTesting this reply, ignore", - format: "org.matrix.custom.html", - formatted_body: "
In reply to @_ooye_kyuugryphon:cadence.moe
Slow news day.
Testing this reply, ignore", - "m.relates_to": { - "m.in_reply_to": { - event_id: "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04" - } - } - } - })(roomID, eventID) - } - } - } - }), - { - messagesToDelete: [], - messagesToEdit: [{ - id: "1144874214311067708", - message: { - username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" - + "\n> Slow news day." - + "\nEditing this reply, which is also a test", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" - } - }], - messagesToSend: [] - } - ) - t.deepEqual(eventsFetched, ["original", "past"]) -}) - -test("event2message: editing a plaintext body message", async t => { - t.deepEqual( - await eventToMessage({ - "type": "m.room.message", - "sender": "@cadence:cadence.moe", - "content": { - "msgtype": "m.text", - "body": " * well, I guess it's no longer brand new... it's existed for mere seconds...", - "m.new_content": { - "msgtype": "m.text", - "body": "well, I guess it's no longer brand new... it's existed for mere seconds..." - }, - "m.relates_to": { - "rel_type": "m.replace", - "event_id": "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs" - } - }, - "origin_server_ts": 1693223873912, - "unsigned": { - "age": 42, - "transaction_id": "m1693223873796.842" - }, - "event_id": "$KxGwvVNzNcmlVbiI2m5kX-jMFNi3Jle71-uu1j7P7vM", - "room_id": "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }, data.guild.general, { - api: { - getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs", { - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "brand new, never before seen message", - } - }) - } - }), - { - messagesToDelete: [], - messagesToEdit: [{ - id: "1145688633186193479", - message: { - username: "cadence [they]", - content: "well, I guess it's no longer brand new... it's existed for mere seconds...", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" - } - }], - messagesToSend: [] - } - ) -}) - -test("event2message: editing a plaintext message to be longer", async t => { - t.deepEqual( - await eventToMessage({ - "type": "m.room.message", - "sender": "@cadence:cadence.moe", - "content": { - "msgtype": "m.text", - "body": " * " + "aaaaaaaaa ".repeat(198) + "well, I guess it's no longer brand new... it's existed for mere seconds..." + "aaaaaaaaa ".repeat(20), - "m.new_content": { - "msgtype": "m.text", - "body": "aaaaaaaaa ".repeat(198) + "well, I guess it's no longer brand new... it's existed for mere seconds..." + "aaaaaaaaa ".repeat(20) - }, - "m.relates_to": { - "rel_type": "m.replace", - "event_id": "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs" - } - }, - "origin_server_ts": 1693223873912, - "unsigned": { - "age": 42, - "transaction_id": "m1693223873796.842" - }, - "event_id": "$KxGwvVNzNcmlVbiI2m5kX-jMFNi3Jle71-uu1j7P7vM", - "room_id": "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }, data.guild.general, { - api: { - getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs", { - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "brand new, never before seen message", - } - }) - } - }), - { - messagesToDelete: [], - messagesToEdit: [{ - id: "1145688633186193479", - message: { - content: "aaaaaaaaa ".repeat(198) + "well, I guess it's", - username: "cadence [they]", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" - } - }], - messagesToSend: [{ - content: "no longer brand new... it's existed for mere seconds..." + ("aaaaaaaaa ".repeat(20)).slice(0, -1), - username: "cadence [they]", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" - }] - } - ) -}) - -test("event2message: editing a plaintext message to be shorter", async t => { - t.deepEqual( - await eventToMessage({ - "type": "m.room.message", - "sender": "@cadence:cadence.moe", - "content": { - "msgtype": "m.text", - "body": " * well, I guess it's no longer brand new... it's existed for mere seconds...", - "m.new_content": { - "msgtype": "m.text", - "body": "well, I guess it's no longer brand new... it's existed for mere seconds..." - }, - "m.relates_to": { - "rel_type": "m.replace", - "event_id": "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSt" - } - }, - "origin_server_ts": 1693223873912, - "unsigned": { - "age": 42, - "transaction_id": "m1693223873796.842" - }, - "event_id": "$KxGwvVNzNcmlVbiI2m5kX-jMFNi3Jle71-uu1j7P7vM", - "room_id": "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }, data.guild.general, { - api: { - getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSt", { - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "aaaaaaaaa ".repeat(198) + "well, I guess it's no longer brand new... it's existed for mere seconds..." + "aaaaaaaaa ".repeat(20) - } - }) - } - }), - { - messagesToDelete: ["1145688633186193481"], - messagesToEdit: [{ - id: "1145688633186193480", - message: { - username: "cadence [they]", - content: "well, I guess it's no longer brand new... it's existed for mere seconds...", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" - } - }], - messagesToSend: [] - } - ) -}) - -test("event2message: editing a formatted body message", async t => { - t.deepEqual( - await eventToMessage({ - "type": "m.room.message", - "sender": "@cadence:cadence.moe", - "content": { - "msgtype": "m.text", - "body": " * **well, I guess it's no longer brand new... it's existed for mere seconds...**", - "format": "org.matrix.custom.html", - "formatted_body": "* well, I guess it's no longer brand new... it's existed for mere seconds...", - "m.new_content": { - "msgtype": "m.text", - "body": "**well, I guess it's no longer brand new... it's existed for mere seconds...**", - "format": "org.matrix.custom.html", - "formatted_body": "well, I guess it's no longer brand new... it's existed for mere seconds..." - }, - "m.relates_to": { - "rel_type": "m.replace", - "event_id": "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs" - } - }, - "origin_server_ts": 1693223873912, - "unsigned": { - "age": 42, - "transaction_id": "m1693223873796.842" - }, - "event_id": "$KxGwvVNzNcmlVbiI2m5kX-jMFNi3Jle71-uu1j7P7vM", - "room_id": "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }, data.guild.general, { - api: { - getEvent: mockGetEvent(t, "!BnKuBPCvyfOkhcUjEu:cadence.moe", "$7LIdiJCEqjcWUrpzWzS8TELOlFfBEe4ytgS7zn2lbSs", { - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "**brand new, never before seen message**", - format: "org.matrix.custom.html", - formatted_body: "brand new, never before seen message" - } - }) - } - }), - { - messagesToDelete: [], - messagesToEdit: [{ - id: "1145688633186193479", - message: { - username: "cadence [they]", - content: "**well, I guess it's no longer brand new... it's existed for mere seconds...**", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" - } - }], - messagesToSend: [] - } - ) -}) - -test("event2message: rich reply to a matrix user's long message with formatting", async t => { - t.deepEqual( - await eventToMessage({ - "type": "m.room.message", - "sender": "@cadence:cadence.moe", - "content": { - "msgtype": "m.text", - "body": "> <@cadence:cadence.moe> ```\n> i should have a little happy test\n> ```\n> * list **bold** _em_ ~~strike~~\n> # heading 1\n> ## heading 2\n> ### heading 3\n> https://cadence.moe\n> [legit website](https://cadence.moe)\n\nno you can't!!!", - "format": "org.matrix.custom.html", - "formatted_body": "
In reply to @cadence:cadence.moe
i should have a little happy test\n
\n
    \n
  • list bold em ~~strike~~
  • \n
\n

heading 1

\n

heading 2

\n

heading 3

\n

https://cadence.moe
legit website

\n
no you can't!!!", - "m.relates_to": { - "m.in_reply_to": { - "event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04" - } - } - }, - "origin_server_ts": 1693037401693, - "unsigned": { - "age": 381, - "transaction_id": "m1693037401592.521" - }, - "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", - "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, { - api: { - getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", { - "type": "m.room.message", - "sender": "@cadence:cadence.moe", - "content": { - "msgtype": "m.text", - "body": "```\ni should have a little happy test\n```\n* list **bold** _em_ ~~strike~~\n# heading 1\n## heading 2\n### heading 3\nhttps://cadence.moe\n[legit website](https://cadence.moe)", - "format": "org.matrix.custom.html", - "formatted_body": "
i should have a little happy test\n
\n
    \n
  • list bold em ~~strike~~
  • \n
\n

heading 1

\n

heading 2

\n

heading 3

\n

https://cadence.moe
legit website

\n" - } - }) - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 Ⓜ️**cadence [they]**:" - + "\n> i should have a little happy test list bold em..." - + "\n**no you can't!!!**", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" - }] - } - ) -}) - -test("event2message: rich reply to an image", async t => { - t.deepEqual( - await eventToMessage({ - "type": "m.room.message", - "sender": "@cadence:cadence.moe", - "content": { - "msgtype": "m.text", - "body": "> <@cadence:cadence.moe> sent an image.\n\nCaught in 8K UHD VR QLED Epic Edition", - "format": "org.matrix.custom.html", - "formatted_body": "
In reply to @cadence:cadence.moe
sent an image.
Caught in 8K UHD VR QLED Epic Edition", - "m.relates_to": { - "m.in_reply_to": { - "event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04" - } - } - }, - "origin_server_ts": 1693037401693, - "unsigned": { - "age": 381, - "transaction_id": "m1693037401592.521" - }, - "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", - "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, { - api: { - getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", { - type: "m.room.message", - sender: "@_ooye_kyuugryphon:cadence.moe", - content: { - "m.mentions": {}, - msgtype: "m.image", - url: "mxc://cadence.moe/ABfYgGdcIECnraZLGpRnoArG", - external_url: "https://cdn.discordapp.com/attachments/1100319550446252084/1149300251648339998/arcafeappx2.png", - body: "arcafeappx2.png", - filename: "arcafeappx2.png", - info: { - mimetype: "image/png", - w: 512, - h: 512, - size: 43990 - } - } - }) - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504> 🖼️" - + "\nCaught in 8K UHD VR QLED Epic Edition", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" - }] - } - ) -}) - -test("event2message: rich reply to a spoiler should ensure the spoiler is hidden", async t => { - t.deepEqual( - await eventToMessage({ - "type": "m.room.message", - "sender": "@cadence:cadence.moe", - "content": { - "msgtype": "m.text", - "body": "> <@cadence:cadence.moe> ||zoe kills a 5 letter noun at the end. don't tell anybody|| cw crossword spoilers you'll never believe\n\nomg NO WAY!!", - "format": "org.matrix.custom.html", - "formatted_body": "
In reply to @cadence:cadence.moe
zoe kills a 5 letter noun at the end. don't tell anybody cw crossword spoilers you'll never believe
omg NO WAY!!", - "m.relates_to": { - "m.in_reply_to": { - "event_id": "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04" - } - } - }, - "origin_server_ts": 1693037401693, - "unsigned": { - "age": 381, - "transaction_id": "m1693037401592.521" - }, - "event_id": "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", - "room_id": "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, { - api: { - getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", { - type: "m.room.message", - sender: "@_ooye_kyuugryphon:cadence.moe", - content: { - "m.mentions": {}, - msgtype: "m.text", - body: "||zoe kills a 5 letter noun at the end. don't tell anybody|| cw crossword spoilers you'll never believe", - format: "org.matrix.custom.html", - formatted_body: `zoe kills a 5 letter noun at the end. don't tell anybody cw crossword spoilers you'll never believe` - } - }) - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 <@111604486476181504>:" - + "\n> [spoiler] cw crossword spoilers you'll never..." - + "\nomg NO WAY!!", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" - }] - } - ) -}) - -test("event2message: with layered rich replies, the preview should only be the real text", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "> <@cadence:cadence.moe> two\n\nthree", - format: "org.matrix.custom.html", - formatted_body: "
In reply to @cadence:cadence.moe
two
three", - "m.relates_to": { - "m.in_reply_to": { - event_id: "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04" - } - } - }, - event_id: "$v_Gtr-bzv9IVlSLBO5DstzwmiDd-GSFaNfHX66IupV8", - room_id: "!fGgIymcYWOqjbSRUdV:cadence.moe" - }, data.guild.general, { - api: { - getEvent: mockGetEvent(t, "!fGgIymcYWOqjbSRUdV:cadence.moe", "$Fxy8SMoJuTduwReVkHZ1uHif9EuvNx36Hg79cltiA04", { - "type": "m.room.message", - "sender": "@cadence:cadence.moe", - "content": { - "msgtype": "m.text", - "body": "> <@cadence:cadence.moe> one\n\ntwo", - "format": "org.matrix.custom.html", - "formatted_body": "
In reply to @cadence:cadence.moe
one
two", - "m.relates_to": { - "m.in_reply_to": { - "event_id": "$5UtboIC30EFlAYD_Oh0pSYVW8JqOp6GsDIJZHtT0Wls" - } - } - } - }) - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "> <:L1:1144820033948762203><:L2:1144820084079087647>https://discord.com/channels/112760669178241024/687028734322147344/1144865310588014633 Ⓜ️**cadence [they]**:" - + "\n> two" - + "\nthree", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU" - }] - } - ) -}) - -test("event2message: raw mentioning discord users in plaintext body works", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "<@114147806469554185> what do you think?" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "<@114147806469554185> what do you think?", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: raw mentioning discord users in formatted body works", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: `<@114147806469554185> what do you think?` - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "<@114147806469554185> what do you think?", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: mentioning discord users works", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: `I'm just testing mentions` - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "I'm just <@114147806469554185> testing mentions", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: mentioning matrix users works", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: `I'm just testing mentions` - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "I'm just [▲]() testing mentions", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: mentioning bridged rooms works", async t => { - t.deepEqual( - await eventToMessage({ - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: `I'm just worm-form testing channel mentions` - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe", - sender: "@cadence:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "I'm just <#1100319550446252084> testing channel mentions", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: caches the member if the member is not known", async t => { - let called = 0 - t.deepEqual( - await eventToMessage({ - content: { - body: "testing the member state cache", - msgtype: "m.text" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!should_be_newly_cached:cadence.moe", - sender: "@should_be_newly_cached:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }, {}, { - api: { - getStateEvent: async (roomID, type, stateKey) => { - called++ - t.equal(roomID, "!should_be_newly_cached:cadence.moe") - t.equal(type, "m.room.member") - t.equal(stateKey, "@should_be_newly_cached:cadence.moe") - return { - avatar_url: "mxc://cadence.moe/this_is_the_avatar" - } - } - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "should_be_newly_cached", - content: "testing the member state cache", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/this_is_the_avatar" - }] - } - ) - - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached:cadence.moe"}).all(), [ - {avatar_url: "mxc://cadence.moe/this_is_the_avatar", displayname: null, mxid: "@should_be_newly_cached:cadence.moe"} - ]) - t.equal(called, 1, "getStateEvent should be called once") -}) - -test("event2message: skips caching the member if the member does not exist, somehow", async t => { - let called = 0 - t.deepEqual( - await eventToMessage({ - content: { - body: "should honestly never happen", - msgtype: "m.text" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!not_real:cadence.moe", - sender: "@not_real:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }, {}, { - api: { - getStateEvent: async (roomID, type, stateKey) => { - called++ - t.equal(roomID, "!not_real:cadence.moe") - t.equal(type, "m.room.member") - t.equal(stateKey, "@not_real:cadence.moe") - throw new MatrixServerError("State event doesn't exist or something") - } - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "not_real", - content: "should honestly never happen", - avatar_url: undefined - }] - } - ) - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!not_real:cadence.moe"}).all(), []) - t.equal(called, 1, "getStateEvent should be called once") -}) - -test("event2message: overly long usernames are shifted into the message content", async t => { - let called = 0 - t.deepEqual( - await eventToMessage({ - content: { - body: "testing the member state cache", - msgtype: "m.text" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!should_be_newly_cached_2:cadence.moe", - sender: "@should_be_newly_cached_2:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }, {}, { - api: { - getStateEvent: async (roomID, type, stateKey) => { - called++ - t.equal(roomID, "!should_be_newly_cached_2:cadence.moe") - t.equal(type, "m.room.member") - t.equal(stateKey, "@should_be_newly_cached_2:cadence.moe") - return { - displayname: "I am BLACK I am WHITE I am SHORT I am LONG I am EVERYTHING YOU THINK IS IMPORTANT and I DON'T MATTER", - } - } - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "I am BLACK I am WHITE I am SHORT I am LONG I am EVERYTHING YOU THINK IS", - content: "**IMPORTANT and I DON'T MATTER**\ntesting the member state cache", - avatar_url: undefined - }] - } - ) - t.deepEqual(select("member_cache", ["avatar_url", "displayname", "mxid"], {room_id: "!should_be_newly_cached_2:cadence.moe"}).all(), [ - {avatar_url: null, displayname: "I am BLACK I am WHITE I am SHORT I am LONG I am EVERYTHING YOU THINK IS IMPORTANT and I DON'T MATTER", mxid: "@should_be_newly_cached_2:cadence.moe"} - ]) - t.equal(called, 1, "getStateEvent should be called once") -}) - -test("event2message: overly long usernames are not treated specially when the msgtype is m.emote", async t => { - t.deepEqual( - await eventToMessage({ - content: { - body: "looks at the start of the message", - msgtype: "m.emote" - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - origin_server_ts: 1688301929913, - room_id: "!should_be_newly_cached_2:cadence.moe", - sender: "@should_be_newly_cached_2:cadence.moe", - type: "m.room.message", - unsigned: { - age: 405299 - } - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "I am BLACK I am WHITE I am SHORT I am LONG I am EVERYTHING YOU THINK IS", - content: "\\* I am BLACK I am WHITE I am SHORT I am LONG I am EVERYTHING YOU THINK IS IMPORTANT and I DON'T MATTER looks at the start of the message", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: text attachments work", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - content: { - body: "chiki-powerups.txt", - info: { - size: 971, - mimetype: "text/plain" - }, - msgtype: "m.file", - url: "mxc://cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH" - }, - sender: "@cadence:cadence.moe", - event_id: "$c2WVyP6KcfAqh5imOa8e0xzt2C8JTR-cWbEd3GargEQ", - room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", filename: "chiki-powerups.txt"}], - pendingFiles: [{name: "chiki-powerups.txt", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH"}] - }] - } - ) -}) - -test("event2message: image attachments work", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - body: "cool cat.png", - info: { - size: 43170, - mimetype: "image/png", - w: 480, - h: 480, - "xyz.amorgan.blurhash": "URTHsVaTpdj2eKZgkkkXp{pHl7feo@lSl9Z$" - }, - msgtype: "m.image", - url: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn" - }, - event_id: "$CXQy3Wmg1A-gL_xAesC1HQcQTEXwICLdSwwUx55FBTI", - room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", filename: "cool cat.png"}], - pendingFiles: [{name: "cool cat.png", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] - }] - } - ) -}) - -test("event2message: encrypted image attachments work", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - info: { - mimetype: "image/png", - size: 105691, - w: 1192, - h: 309, - "xyz.amorgan.blurhash": "U17USN~q9FtQ-;Rjxuj[9FIUoMM|-=WB9Ft7" - }, - msgtype: "m.image", - body: "image.png", - file: { - v: "v2", - key: { - alg: "A256CTR", - ext: true, - k: "QTo-oMPnN1Rbc7vBFg9WXMgoctscdyxdFEIYm8NYceo", - key_ops: ["encrypt", "decrypt"], - kty: "oct" - }, - iv: "Va9SHZpIn5kAAAAAAAAAAA", - hashes: { - sha256: "OUZqZFBcANFt42iAKET9YXfWMCdT0BX7QO0Eyk9q4Js" - }, - url: "mxc://heyquark.com/LOGkUTlVFrqfiExlGZNgCJJX", - mimetype: "image/png" - } - }, - event_id: "$JNhONhXO-5jrztZz8b7mbTMJasbU78TwQr4tog-3Mnk", - room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", filename: "image.png"}], - pendingFiles: [{ - name: "image.png", - url: "https://matrix.cadence.moe/_matrix/media/r0/download/heyquark.com/LOGkUTlVFrqfiExlGZNgCJJX", - key: "QTo-oMPnN1Rbc7vBFg9WXMgoctscdyxdFEIYm8NYceo", - iv: "Va9SHZpIn5kAAAAAAAAAAA" - }] - }] - } - ) -}) - -test("event2message: stickers work", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.sticker", - sender: "@cadence:cadence.moe", - content: { - body: "get_real2", - url: "mxc://cadence.moe/NyMXQFAAdniImbHzsygScbmN", - info: { - w: 320, - h: 298, - mimetype: "image/gif", - size: 331394, - thumbnail_info: { - w: 320, - h: 298, - mimetype: "image/gif", - size: 331394 - }, - thumbnail_url: "mxc://cadence.moe/NyMXQFAAdniImbHzsygScbmN" - } - }, - event_id: "$PdI-KjdQ8Z_Tb4x9_7wKRPZCsrrXym4BXtbAPekypuM", - room_id: "!BnKuBPCvyfOkhcUjEu:cadence.moe" - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "", - avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", - attachments: [{id: "0", filename: "get_real2.gif"}], - pendingFiles: [{name: "get_real2.gif", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/NyMXQFAAdniImbHzsygScbmN"}] - }] - } - ) -}) - -test("event2message: static emojis work", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: ":hippo:", - format: "org.matrix.custom.html", - formatted_body: '\":hippo:\"' - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "<:hippo:230201364309868544>", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: animated emojis work", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: ":hippo:", - format: "org.matrix.custom.html", - formatted_body: '\":hipposcope:\"' - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "", - avatar_url: undefined - }] - } - ) -}) - -test("event2message: unknown emojis in the middle are linked", async t => { - t.deepEqual( - await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: 'a \":ms_robot_grin:\" b' - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }), - { - messagesToDelete: [], - messagesToEdit: [], - messagesToSend: [{ - username: "cadence [they]", - content: "a [:ms_robot_grin:](https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/RLMgJGfgTPjIQtvvWZsYjhjy) b", - avatar_url: undefined - }] - } - ) -}) - -slow()("event2message: unknown emoji in the end is reuploaded as a sprite sheet", async t => { - const messages = await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: 'a b \":ms_robot_grin:\"' - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }) - const testResult = { - content: messages.messagesToSend[0].content, - fileName: messages.messagesToSend[0].pendingFiles[0].name, - fileContentStart: messages.messagesToSend[0].pendingFiles[0].buffer.subarray(0, 90).toString("base64") - } - t.deepEqual(testResult, { - content: "a b", - fileName: "emojis.png", - fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALkklEQVR4nM1ZeWyUxxV/azAGwn0JMJUppPhce++1Oc1i" - }) -}) - -slow()("event2message: known and unknown emojis in the end are reuploaded as a sprite sheet", async t => { - const messages = await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "wrong body", - format: "org.matrix.custom.html", - formatted_body: 'known unknown: \":hippo:\" \":ms_robot_dress:\" and known unknown: \":hipposcope:\" \":ms_robot_cat:\"' - }, - event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU", - room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" - }) - const testResult = { - content: messages.messagesToSend[0].content, - fileName: messages.messagesToSend[0].pendingFiles[0].name, - fileContentStart: messages.messagesToSend[0].pendingFiles[0].buffer.subarray(0, 90).toString("base64") - } - t.deepEqual(testResult, { - content: "known unknown: <:hippo:230201364309868544> [:ms_robot_dress:](https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/wcouHVjbKJJYajkhJLsyeJAA) and known unknown:", - fileName: "emojis.png", - fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAAGAAAAAwCAYAAADuFn/PAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAT5UlEQVR4nOVbCXSVRZauR9gMsoYlvKwvARKSkPUlJOyL" - }) -}) - -slow()("event2message: all unknown chess emojis are reuploaded as a sprite sheet", async t => { - const messages = await eventToMessage({ - type: "m.room.message", - sender: "@cadence:cadence.moe", - content: { - msgtype: "m.text", - body: "testing :chess_good_move::chess_incorrect::chess_blund::chess_brilliant_move::chess_blundest::chess_draw_black::chess_good_move::chess_incorrect::chess_blund::chess_brilliant_move::chess_blundest::chess_draw_black:", - format: "org.matrix.custom.html", - formatted_body: "testing \":chess_good_move:\"\":chess_incorrect:\"\":chess_blund:\"\":chess_brilliant_move:\"\":chess_blundest:\"\":chess_draw_black:\"\":chess_good_move:\"\":chess_incorrect:\"\":chess_blund:\"\":chess_brilliant_move:\"\":chess_blundest:\"\":chess_draw_black:\"" - }, - event_id: "$Me6iE8C8CZyrDEOYYrXKSYRuuh_25Jj9kZaNrf7LKr4", - room_id: "!maggESguZBqGBZtSnr:cadence.moe" - }) - const testResult = { - content: messages.messagesToSend[0].content, - fileName: messages.messagesToSend[0].pendingFiles[0].name, - fileContentStart: messages.messagesToSend[0].pendingFiles[0].buffer.subarray(0, 90).toString("base64") - } - t.deepEqual(testResult, { - content: "testing", - fileName: "emojis.png", - fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAASAAAAAwCAYAAACxIqevAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAgAElEQVR4nOV9B1xUV9r3JMbEGBQLbRodhukDg2jWZP02" - }) -}) diff --git a/m2d/converters/utils.js b/m2d/converters/utils.js deleted file mode 100644 index 82b241e..0000000 --- a/m2d/converters/utils.js +++ /dev/null @@ -1,69 +0,0 @@ -// @ts-check - -const reg = require("../../matrix/read-registration") -const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) -const assert = require("assert").strict -/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore -let hasher = null -// @ts-ignore -require("xxhash-wasm")().then(h => hasher = h) - -const BLOCK_ELEMENTS = [ - "ADDRESS", "ARTICLE", "ASIDE", "AUDIO", "BLOCKQUOTE", "BODY", "CANVAS", - "CENTER", "DD", "DETAILS", "DIR", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE", - "FOOTER", "FORM", "FRAMESET", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER", - "HGROUP", "HR", "HTML", "ISINDEX", "LI", "MAIN", "MENU", "NAV", "NOFRAMES", - "NOSCRIPT", "OL", "OUTPUT", "P", "PRE", "SECTION", "SUMMARY", "TABLE", "TBODY", "TD", - "TFOOT", "TH", "THEAD", "TR", "UL" -] - -/** - * Determine whether an event is the bridged representation of a discord message. - * Such messages shouldn't be bridged again. - * @param {string} sender - */ -function eventSenderIsFromDiscord(sender) { - // If it's from a user in the bridge's namespace, then it originated from discord - // This includes messages sent by the appservice's bot user, because that is what's used for webhooks - // TODO: It would be nice if bridge system messages wouldn't trigger this check and could be bridged from matrix to discord, while webhook reflections would remain ignored... - // TODO that only applies to the above todo: But you'd have to watch out for the /icon command, where the bridge bot would set the room avatar, and that shouldn't be reflected into the room a second time. - if (userRegex.some(x => sender.match(x))) { - return true - } - - return false -} - -/** - * @param {string} mxc - * @returns {string?} - */ -function getPublicUrlForMxc(mxc) { - const avatarURLParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) - if (avatarURLParts) return `${reg.ooye.server_origin}/_matrix/media/r0/download/${avatarURLParts[1]}/${avatarURLParts[2]}` - else return null -} - -/** - * Event IDs are really big and have more entropy than we need. - * If we want to store the event ID in the database, we can store a more compact version by hashing it with this. - * I choose a 64-bit non-cryptographic hash as only a 32-bit hash will see birthday collisions unreasonably frequently: https://en.wikipedia.org/wiki/Birthday_attack#Mathematics - * xxhash outputs an unsigned 64-bit integer. - * Converting to a signed 64-bit integer with no bit loss so that it can be stored in an SQLite integer field as-is: https://www.sqlite.org/fileformat2.html#record_format - * This should give very efficient storage with sufficient entropy. - * @param {string} eventID - */ -function getEventIDHash(eventID) { - assert(hasher, "xxhash is not ready yet") - if (eventID[0] === "$" && eventID.length >= 13) { - eventID = eventID.slice(1) // increase entropy per character to potentially help xxhash - } - const unsignedHash = hasher.h64(eventID) - const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range - return signedHash -} - -module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS -module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord -module.exports.getPublicUrlForMxc = getPublicUrlForMxc -module.exports.getEventIDHash = getEventIDHash diff --git a/m2d/converters/utils.test.js b/m2d/converters/utils.test.js deleted file mode 100644 index 9d039fe..0000000 --- a/m2d/converters/utils.test.js +++ /dev/null @@ -1,25 +0,0 @@ -// @ts-check - -const {test} = require("supertape") -const {eventSenderIsFromDiscord, getEventIDHash} = require("./utils") - -test("sender type: matrix user", t => { - t.notOk(eventSenderIsFromDiscord("@cadence:cadence.moe")) -}) - -test("sender type: ooye bot", t => { - t.ok(eventSenderIsFromDiscord("@_ooye_bot:cadence.moe")) -}) - -test("sender type: ooye puppet", t => { - t.ok(eventSenderIsFromDiscord("@_ooye_sheep:cadence.moe")) -}) - -test("event hash: hash is the same each time", t => { - const eventID = "$example" - t.equal(getEventIDHash(eventID), getEventIDHash(eventID)) -}) - -test("event hash: hash is different for different inputs", t => { - t.notEqual(getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe1"), getEventIDHash("$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe2")) -}) diff --git a/m2d/event-dispatcher.js b/m2d/event-dispatcher.js deleted file mode 100644 index f7e5e1b..0000000 --- a/m2d/event-dispatcher.js +++ /dev/null @@ -1,152 +0,0 @@ -// @ts-check - -/* - * Grab Matrix events we care about, check them, and bridge them. - */ - -const util = require("util") -const Ty = require("../types") -const {discord, db, sync, as} = require("../passthrough") - -/** @type {import("./actions/send-event")} */ -const sendEvent = sync.require("./actions/send-event") -/** @type {import("./actions/add-reaction")} */ -const addReaction = sync.require("./actions/add-reaction") -/** @type {import("./actions/redact")} */ -const redact = sync.require("./actions/redact") -/** @type {import("../matrix/matrix-command-handler")} */ -const matrixCommandHandler = sync.require("../matrix/matrix-command-handler") -/** @type {import("./converters/utils")} */ -const utils = sync.require("./converters/utils") -/** @type {import("../matrix/api")}) */ -const api = sync.require("../matrix/api") -/** @type {import("../matrix/read-registration")}) */ -const reg = sync.require("../matrix/read-registration") - -let lastReportedEvent = 0 - -function guard(type, fn) { - return async function(event, ...args) { - try { - return await fn(event, ...args) - } catch (e) { - console.error("hit event-dispatcher's error handler with this exception:") - console.error(e) // TODO: also log errors into a file or into the database, maybe use a library for this? or just wing it? - console.error(`while handling this ${type} gateway event:`) - console.dir(event, {depth: null}) - - if (Date.now() - lastReportedEvent < 5000) return - lastReportedEvent = Date.now() - - let stackLines = e.stack.split("\n") - api.sendEvent(event.room_id, "m.room.message", { - msgtype: "m.text", - body: "\u26a0 Matrix event not delivered to Discord. See formatted content for full details.", - format: "org.matrix.custom.html", - formatted_body: "\u26a0 Matrix event not delivered to Discord" - + `
Event type: ${type}` - + `
${e.toString()}` - + `
Error trace` - + `
${stackLines.join("\n")}
` - + `
Original payload` - + `
${util.inspect(event, false, 4, false)}
`, - "moe.cadence.ooye.error": { - source: "matrix", - payload: event - }, - "m.mentions": { - user_ids: ["@cadence:cadence.moe"] - } - }) - } - } -} - -async function retry(roomID, eventID) { - const event = await api.getEvent(roomID, eventID) - const error = event.content["moe.cadence.ooye.error"] - if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return - if (error.source === "matrix") { - as.emit("type:" + error.payload.type, error.payload) - } else if (error.source === "discord") { - discord.cloud.emit("event", error.payload) - } -} - -sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", -/** - * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File} event it is a m.room.message because that's what this listener is filtering for - */ -async event => { - if (utils.eventSenderIsFromDiscord(event.sender)) return - const messageResponses = await sendEvent.sendEvent(event) - if (event.type === "m.room.message" && event.content.msgtype === "m.text") { - // @ts-ignore - await matrixCommandHandler.execute(event) - } -})) - -sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker", -/** - * @param {Ty.Event.Outer_M_Sticker} event it is a m.sticker because that's what this listener is filtering for - */ -async event => { - if (utils.eventSenderIsFromDiscord(event.sender)) return - const messageResponses = await sendEvent.sendEvent(event) -})) - -sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction", -/** - * @param {Ty.Event.Outer} event it is a m.reaction because that's what this listener is filtering for - */ -async event => { - if (utils.eventSenderIsFromDiscord(event.sender)) return - if (event.content["m.relates_to"].key === "🔁") { - // Try to bridge a failed event again? - await retry(event.room_id, event.content["m.relates_to"].event_id) - } else { - matrixCommandHandler.onReactionAdd(event) - await addReaction.addReaction(event) - } -})) - -sync.addTemporaryListener(as, "type:m.room.redaction", guard("m.room.redaction", -/** - * @param {Ty.Event.Outer_M_Room_Redaction} event it is a m.room.redaction because that's what this listener is filtering for - */ -async event => { - if (utils.eventSenderIsFromDiscord(event.sender)) return - await redact.handle(event) -})) - -sync.addTemporaryListener(as, "type:m.room.avatar", guard("m.room.avatar", -/** - * @param {Ty.Event.StateOuter} event - */ -async event => { - if (event.state_key !== "") return - if (utils.eventSenderIsFromDiscord(event.sender)) return - const url = event.content.url || null - db.prepare("UPDATE channel_room SET custom_avatar = ? WHERE room_id = ?").run(url, event.room_id) -})) - -sync.addTemporaryListener(as, "type:m.room.name", guard("m.room.name", -/** - * @param {Ty.Event.StateOuter} event - */ -async event => { - if (event.state_key !== "") return - if (utils.eventSenderIsFromDiscord(event.sender)) return - const name = event.content.name || null - db.prepare("UPDATE channel_room SET nick = ? WHERE room_id = ?").run(name, event.room_id) -})) - -sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member", -/** - * @param {Ty.Event.StateOuter} event - */ -async event => { - if (event.state_key[0] !== "@") return - if (utils.eventSenderIsFromDiscord(event.state_key)) return - db.prepare("REPLACE INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?)").run(event.room_id, event.state_key, event.content.displayname || null, event.content.avatar_url || null) -})) diff --git a/matrix/api.js b/matrix/api.js deleted file mode 100644 index 5509930..0000000 --- a/matrix/api.js +++ /dev/null @@ -1,236 +0,0 @@ -// @ts-check - -const Ty = require("../types") -const assert = require("assert").strict - -const passthrough = require("../passthrough") -const { discord, sync, db } = passthrough -/** @type {import("./mreq")} */ -const mreq = sync.require("./mreq") -/** @type {import("./file")} */ -const file = sync.require("./file") -/** @type {import("./txnid")} */ -const makeTxnId = sync.require("./txnid") - -/** - * @param {string} p endpoint to access - * @param {string?} [mxid] optional: user to act as, for the ?user_id parameter - * @param {{[x: string]: any}} [otherParams] optional: any other query parameters to add - * @returns {string} the new endpoint - */ -function path(p, mxid, otherParams = {}) { - if (!mxid) return p - const u = new URL(p, "http://localhost") - u.searchParams.set("user_id", mxid) - for (const entry of Object.entries(otherParams)) { - if (entry[1] != undefined) { - u.searchParams.set(entry[0], entry[1]) - } - } - return u.pathname + "?" + u.searchParams.toString() -} - -/** - * @param {string} username - * @returns {Promise} - */ -function register(username) { - console.log(`[api] register: ${username}`) - return mreq.mreq("POST", "/client/v3/register", { - type: "m.login.application_service", - username - }) -} - -/** - * @returns {Promise} room ID - */ -async function createRoom(content) { - console.log(`[api] create room:`, content) - /** @type {Ty.R.RoomCreated} */ - const root = await mreq.mreq("POST", "/client/v3/createRoom", content) - return root.room_id -} - -/** - * @returns {Promise} room ID - */ -async function joinRoom(roomIDOrAlias, mxid) { - /** @type {Ty.R.RoomJoined} */ - const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid)) - return root.room_id -} - -async function inviteToRoom(roomID, mxidToInvite, mxid) { - await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/invite`, mxid), { - user_id: mxidToInvite - }) -} - -async function leaveRoom(roomID, mxid) { - await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {}) -} - -/** - * @param {string} roomID - * @param {string} eventID - * @template T - */ -async function getEvent(roomID, eventID) { - /** @type {Ty.Event.Outer} */ - const root = await mreq.mreq("GET", `/client/v3/rooms/${roomID}/event/${eventID}`) - return root -} - -/** - * @param {string} roomID - * @returns {Promise} - */ -function getAllState(roomID) { - return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state`) -} - -/** - * @param {string} roomID - * @param {string} type - * @param {string} key - * @returns the *content* of the state event - */ -function getStateEvent(roomID, type, key) { - return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}`) -} - -/** - * "Any of the AS's users must be in the room. This API is primarily for Application Services and should be faster to respond than /members as it can be implemented more efficiently on the server." - * @param {string} roomID - * @returns {Promise<{joined: {[mxid: string]: {avatar_url?: string, display_name?: string}}}>} - */ -function getJoinedMembers(roomID) { - return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`) -} - -/** - * @param {string} roomID - * @param {string} eventID - * @param {{from?: string, limit?: any}} pagination - * @param {string?} [relType] - * @returns {Promise>>} - */ -function getRelations(roomID, eventID, pagination, relType) { - let path = `/client/v1/rooms/${roomID}/relations/${eventID}` - if (relType) path += `/${relType}` - if (!pagination.from) delete pagination.from - if (!pagination.limit) pagination.limit = 50 // get a little more consistency between homeservers - path += `?${new URLSearchParams(pagination)}` - return mreq.mreq("GET", path) -} - -/** - * @param {string} roomID - * @param {string} type - * @param {string} stateKey - * @param {string} [mxid] - * @returns {Promise} event ID - */ -async function sendState(roomID, type, stateKey, content, mxid) { - console.log(`[api] state: ${roomID}: ${type}/${stateKey}`) - assert.ok(type) - assert.ok(typeof stateKey === "string") - /** @type {Ty.R.EventSent} */ - // encodeURIComponent is necessary because state key can contain some special characters like / but you must encode them so they fit in a single component of the URI - const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/state/${type}/${encodeURIComponent(stateKey)}`, mxid), content) - return root.event_id -} - -/** - * @param {string} roomID - * @param {string} type - * @param {any} content - * @param {string?} [mxid] - * @param {number} [timestamp] timestamp of the newly created event, in unix milliseconds - */ -async function sendEvent(roomID, type, content, mxid, timestamp) { - if (!["m.room.message", "m.reaction", "m.sticker"].includes(type)) { - console.log(`[api] event ${type} to ${roomID} as ${mxid || "default sim"}`) - } - /** @type {Ty.R.EventSent} */ - const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/send/${type}/${makeTxnId.makeTxnId()}`, mxid, {ts: timestamp}), content) - return root.event_id -} - -/** - * @param {string} roomID - * @param {string} eventID - * @param {string?} [mxid] - * @returns {Promise} event ID - */ -async function redactEvent(roomID, eventID, mxid) { - /** @type {Ty.R.EventRedacted} */ - const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/redact/${eventID}/${makeTxnId.makeTxnId()}`, mxid), {}) - return root.event_id -} - -/** - * @param {string} roomID - * @param {boolean} isTyping - * @param {string} mxid - * @param {number} [duration] milliseconds - */ -async function sendTyping(roomID, isTyping, mxid, duration) { - await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/typing/${mxid}`, mxid), { - typing: isTyping, - timeout: duration - }) -} - -async function profileSetDisplayname(mxid, displayname) { - await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid), { - displayname - }) -} - -async function profileSetAvatarUrl(mxid, avatar_url) { - await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid), { - avatar_url - }) -} - -/** - * Set a user's power level within a room. - * @param {string} roomID - * @param {string} mxid - * @param {number} power - */ -async function setUserPower(roomID, mxid, power) { - assert(roomID[0] === "!") - assert(mxid[0] === "@") - // Yes there's no shortcut https://github.com/matrix-org/matrix-appservice-bridge/blob/2334b0bae28a285a767fe7244dad59f5a5963037/src/components/intent.ts#L352 - const powerLevels = await getStateEvent(roomID, "m.room.power_levels", "") - powerLevels.users = powerLevels.users || {} - if (power != null) { - powerLevels.users[mxid] = power - } else { - delete powerLevels.users[mxid] - } - await sendState(roomID, "m.room.power_levels", "", powerLevels) - return powerLevels -} - -module.exports.path = path -module.exports.register = register -module.exports.createRoom = createRoom -module.exports.joinRoom = joinRoom -module.exports.inviteToRoom = inviteToRoom -module.exports.leaveRoom = leaveRoom -module.exports.getEvent = getEvent -module.exports.getAllState = getAllState -module.exports.getStateEvent = getStateEvent -module.exports.getJoinedMembers = getJoinedMembers -module.exports.getRelations = getRelations -module.exports.sendState = sendState -module.exports.sendEvent = sendEvent -module.exports.redactEvent = redactEvent -module.exports.sendTyping = sendTyping -module.exports.profileSetDisplayname = profileSetDisplayname -module.exports.profileSetAvatarUrl = profileSetAvatarUrl -module.exports.setUserPower = setUserPower diff --git a/matrix/appservice.js b/matrix/appservice.js deleted file mode 100644 index e99a822..0000000 --- a/matrix/appservice.js +++ /dev/null @@ -1,8 +0,0 @@ -const reg = require("../matrix/read-registration") -const AppService = require("matrix-appservice").AppService -const as = new AppService({ - homeserverToken: reg.hs_token -}) -as.listen(+(new URL(reg.url).port)) - -module.exports = as diff --git a/matrix/mreq.js b/matrix/mreq.js deleted file mode 100644 index 4291ede..0000000 --- a/matrix/mreq.js +++ /dev/null @@ -1,66 +0,0 @@ -// @ts-check - -const fetch = require("node-fetch").default -const mixin = require("mixin-deep") - -const passthrough = require("../passthrough") -const { sync } = passthrough -/** @type {import("./read-registration")} */ -const reg = sync.require("./read-registration.js") - -const baseUrl = `${reg.ooye.server_origin}/_matrix` - -class MatrixServerError extends Error { - constructor(data, opts) { - super(data.error || data.errcode) - this.data = data - /** @type {string} */ - this.errcode = data.errcode - this.opts = opts - } -} - -/** - * @param {string} method - * @param {string} url - * @param {any} [body] - * @param {any} [extra] - */ -async function mreq(method, url, body, extra = {}) { - const opts = mixin({ - method, - body: (body == undefined || Object.is(body.constructor, Object)) ? JSON.stringify(body) : body, - headers: { - Authorization: `Bearer ${reg.as_token}` - } - }, extra) - - // console.log(baseUrl + url, opts) - const res = await fetch(baseUrl + url, opts) - const root = await res.json() - - if (!res.ok || root.errcode) throw new MatrixServerError(root, opts) - return root -} - -/** - * JavaScript doesn't have Racket-like parameters with dynamic scoping, so - * do NOT do anything else at the same time as this. - * @template T - * @param {string} token - * @param {(...arg: any[]) => Promise} callback - * @returns {Promise} - */ -async function withAccessToken(token, callback) { - const prevToken = reg.as_token - reg.as_token = token - try { - return await callback() - } finally { - reg.as_token = prevToken - } -} - -module.exports.MatrixServerError = MatrixServerError -module.exports.mreq = mreq -module.exports.withAccessToken = withAccessToken diff --git a/matrix/read-registration.js b/matrix/read-registration.js deleted file mode 100644 index c431907..0000000 --- a/matrix/read-registration.js +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-check - -const fs = require("fs") -const assert = require("assert").strict -const yaml = require("js-yaml") - -/** @ts-ignore @type {import("../types").AppServiceRegistrationConfig} */ -const reg = yaml.load(fs.readFileSync("registration.yaml", "utf8")) -reg["ooye"].invite = (reg.ooye.invite || []).filter(mxid => mxid.endsWith(`:${reg.ooye.server_name}`)) // one day I will understand why typescript disagrees with dot notation on this line -assert(reg.ooye.max_file_size) -assert(reg.ooye.namespace_prefix) -assert(reg.ooye.server_name) - -module.exports = reg diff --git a/matrix/read-registration.test.js b/matrix/read-registration.test.js deleted file mode 100644 index e5123b9..0000000 --- a/matrix/read-registration.test.js +++ /dev/null @@ -1,10 +0,0 @@ -const {test} = require("supertape") -const reg = require("./read-registration") - -test("reg: has necessary parameters", t => { - const propertiesToCheck = ["sender_localpart", "id", "as_token", "ooye"] - t.deepEqual( - propertiesToCheck.filter(p => p in reg), - propertiesToCheck - ) -}) diff --git a/package-lock.json b/package-lock.json index 3847ee1..aa7822f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,56 +1,128 @@ { "name": "out-of-your-element", - "version": "1.1.1", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "out-of-your-element", - "version": "1.1.1", + "version": "3.1.0", "license": "AGPL-3.0-or-later", "dependencies": { - "@chriscdn/promise-semaphore": "^2.0.1", - "better-sqlite3": "^8.3.0", + "@chriscdn/promise-semaphore": "^3.0.1", + "@cloudrac3r/discord-markdown": "^2.6.6", + "@cloudrac3r/giframe": "^0.4.3", + "@cloudrac3r/html-template-tag": "^5.0.1", + "@cloudrac3r/in-your-element": "^1.1.1", + "@cloudrac3r/mixin-deep": "^3.0.1", + "@cloudrac3r/pngjs": "^7.0.3", + "@cloudrac3r/pug": "^4.0.4", + "@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.8.0", - "discord-markdown": "git+https://git.sr.ht/~cadence/nodejs-discord-markdown#abc56d544072a1dc5624adfea455b0e902adf7b3", - "entities": "^4.5.0", - "giframe": "github:cloudrac3r/giframe#v0.4.1", - "heatsync": "^2.4.1", - "js-yaml": "^4.1.0", - "matrix-appservice": "^2.0.0", - "minimist": "^1.2.8", - "mixin-deep": "github:cloudrac3r/mixin-deep#v3.0.0", - "node-fetch": "^2.6.7", - "pngjs": "^7.0.0", + "cloudstorm": "^0.14.0", + "discord-api-types": "^0.38.19", + "domino": "^2.1.6", + "enquirer": "^2.4.1", + "entities": "^5.0.0", + "get-relative-path": "^1.0.2", + "h3": "^1.15.1", + "heatsync": "^2.7.2", + "htmx.org": "^2.0.4", + "lru-cache": "^11.0.2", "prettier-bytes": "^1.0.4", - "sharp": "^0.32.6", - "snowtransfer": "^0.8.0", + "sharp": "^0.33.4", + "snowtransfer": "^0.15.0", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", - "turndown": "^7.1.2", - "xxhash-wasm": "^1.0.2" + "uqr": "^0.1.2", + "xxhash-wasm": "^1.0.2", + "zod": "^4.0.17" }, "devDependencies": { - "@types/node": "^18.16.0", - "@types/node-fetch": "^2.6.3", - "c8": "^8.0.1", + "@cloudrac3r/tap-dot": "^2.0.3", + "@types/node": "^22.17.1", + "c8": "^10.1.2", "cross-env": "^7.0.3", - "discord-api-types": "^0.37.53", - "supertape": "^8.3.0", - "tap-dot": "github:cloudrac3r/tap-dot#9dd7750ececeae3a96afba91905be812b6b2cc2d" + "supertape": "^11.3.0" + }, + "engines": { + "node": ">=20" + } + }, + "../tap-dot": { + "name": "@cloudrac3r/tap-dot", + "version": "2.0.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@cloudrac3r/tap-out": "^3.2.3", + "ansi-colors": "^4.1.3" + }, + "bin": { + "tap-dot": "bin/dot" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", + "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", + "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", + "dependencies": { + "@babel/types": "^7.25.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", + "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", + "dependencies": { + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" } }, "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "engines": { + "node": ">=18" + } }, "node_modules/@chriscdn/promise-semaphore": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-2.0.1.tgz", - "integrity": "sha512-C0Ku5DNZFbafbSRXagidIaRgzhgGmSHk4aAgPpmmHEostazBiSaMryovC/Aix3vRLNuaeGDKN/DHoNECmMD6jg==" + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-3.1.1.tgz", + "integrity": "sha512-ALLLLYlPfd/QZLptcVi6HQRK1zaCDWZoqYYw+axLmCatFs4gVTSZ5nqlyxwFe4qwR/K84HvOMa9hxda881FqMA==", + "license": "MIT" }, "node_modules/@cloudcmd/stub": { "version": "4.0.1", @@ -152,6 +224,609 @@ "node": ">=8" } }, + "node_modules/@cloudrac3r/discord-markdown": { + "version": "2.6.6", + "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.6.tgz", + "integrity": "sha512-4FNO7WmACPvcTrQjeLQLr9WRuP7JDUVUGFrRJvmAjiMs2UlUAsShfSRuU2SCqz3QqmX8vyJ06wy2hkjTTyRtbw==", + "license": "MIT", + "dependencies": { + "simple-markdown": "^0.7.3" + } + }, + "node_modules/@cloudrac3r/giframe": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@cloudrac3r/giframe/-/giframe-0.4.3.tgz", + "integrity": "sha512-LKuRfdHrhvgPP0heYdlVRecswk/kYaC3fI+X+GQmnkJE36uN1E2dg5l5QdLoukliH7g8S2hgDYk0jsR7sJf8Dg==" + }, + "node_modules/@cloudrac3r/html-template-tag": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@cloudrac3r/html-template-tag/-/html-template-tag-5.0.1.tgz", + "integrity": "sha512-aH+ZdWJf53E63bVb2FiSnpM81qtF2ZNVbrXjrHcfnofyV/GTYJjZHnmPYC2FgXxJ+I8+bZP3DiwYzj7zXYoekw==", + "dependencies": { + "html-es6cape": "^2.0.0" + } + }, + "node_modules/@cloudrac3r/in-your-element": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cloudrac3r/in-your-element/-/in-your-element-1.1.1.tgz", + "integrity": "sha512-AKp9vnSDA9wzJl4O3C/LA8jgI5m1r0M3MRBQGHcVVL22SrrZMdcy+kWjlZWK343KVLOkuTAISA2D+Jb/zyZS6A==", + "license": "AGPL-3.0-or-later", + "dependencies": { + "h3": "^1.12.0", + "zod": "^4.0.17" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudrac3r/mixin-deep": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@cloudrac3r/mixin-deep/-/mixin-deep-3.0.1.tgz", + "integrity": "sha512-awxfIraHjJ/URNlZ0ROc78Tdjtfk/fM/Gnj1embfrSN08h/HpRtLmPc3xlG3T2vFAy1AkONaebd52u7o6kDaYw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@cloudrac3r/pngjs": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@cloudrac3r/pngjs/-/pngjs-7.0.3.tgz", + "integrity": "sha512-Aghuja9XAIqBPmY2jk8dKZSyK90gImxA4hJeEYYAWkZO34bf+zliUAvGBygoBZA0EgXSmfxewVchL+9y3w+rDw==", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/@cloudrac3r/pug": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@cloudrac3r/pug/-/pug-4.0.4.tgz", + "integrity": "sha512-RZhxM/WfSHT0n39URlwDdugBfGfwEWmr+w+mCyiT9jaiqCjeZPpXkps/cWLA1XRLo7fzq0+9THtGzVKXS487/A==", + "dependencies": { + "@cloudrac3r/pug-code-gen": "3.0.5", + "@cloudrac3r/pug-lexer": "5.0.3", + "pug-linker": "^4.0.0", + "pug-load": "^3.0.0", + "pug-parser": "^6.0.0", + "pug-runtime": "^3.0.1", + "pug-strip-comments": "^2.0.0" + } + }, + "node_modules/@cloudrac3r/pug-code-gen": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@cloudrac3r/pug-code-gen/-/pug-code-gen-3.0.5.tgz", + "integrity": "sha512-dKKpy3i9YlVa3lBgu5Jds513c7AtzmmsR2/lGhY2NOODSpIiTcbWLw1obA9YEmmH1tAJny+J6ePYN1N1RgjjQA==", + "dependencies": { + "constantinople": "^4.0.1", + "doctypes": "^1.1.0", + "js-stringify": "^1.0.2", + "pug-attrs": "^3.0.0", + "pug-error": "^2.1.0", + "pug-runtime": "^3.0.1", + "void-elements": "^3.1.0", + "with": "^7.0.0" + } + }, + "node_modules/@cloudrac3r/pug-lexer": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@cloudrac3r/pug-lexer/-/pug-lexer-5.0.3.tgz", + "integrity": "sha512-ym4g4q+l9IC2H1wXCDnF79AQZ48xtxO675JOT316e17W2wHWtgRccXpT6DkBAaRDZycmkGzSxID1S15T2lZj+g==", + "dependencies": { + "character-parser": "^4.0.0", + "is-expression": "^4.0.0", + "pug-error": "^2.1.0" + } + }, + "node_modules/@cloudrac3r/tap-dot": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@cloudrac3r/tap-dot/-/tap-dot-2.0.3.tgz", + "integrity": "sha512-GmdDvc9LCdD4IvKf5DhTTSywGzIT8KbxjhNFSewnaUEfiGDer65CK62KzdciZgWBZfow8xaHISGfXgThn5/rqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cloudrac3r/tap-out": "^3.2.3", + "ansi-colors": "^4.1.3" + }, + "bin": { + "tap-dot": "bin/dot" + } + }, + "node_modules/@cloudrac3r/tap-out": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@cloudrac3r/tap-out/-/tap-out-3.2.3.tgz", + "integrity": "sha512-WS89ziuTMLZlOVTXArkok32HcpWWxejQmZAUOl6nbB2Y114M8LfSOQ7t/rmgVsYB4oc6Ehe/nzevUMt4GGbCsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "re-emitter": "1.1.4", + "split2": "^4.2.0" + }, + "bin": { + "tap-in": "bin/tap-in.js" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@cloudrac3r/turndown": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.4.tgz", + "integrity": "sha512-bQAwcvcSqBTdEHPMt+IAZWIoDh+2eRuy9TgD0FUdxVurbvj3CUHTxLfzlmsO0UTi+GHpgYqDSsVdV7kYTNq5Qg==", + "dependencies": { + "domino": "^2.1.6" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz", + "integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@hotwired/stimulus": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz", + "integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==" + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -161,16 +836,37 @@ "node": ">=8" } }, - "node_modules/@jest/schemas": { - "version": "29.4.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.4.3.tgz", - "integrity": "sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg==", + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.0.1.tgz", + "integrity": "sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.25.16" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -198,61 +894,100 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@putout/cli-keypress": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@putout/cli-keypress/-/cli-keypress-1.0.0.tgz", - "integrity": "sha512-w+lRVGZodRM4K214R4jvyOsmCUGA3OnaYDOJ2rpXj6a+O6n91zLlkb7JYsw6I0QCNmXjpNLJSoLgzGWTue6YIg==", + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@putout/cli-keypress": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@putout/cli-keypress/-/cli-keypress-3.0.0.tgz", + "integrity": "sha512-RwODGTbcWNaulEPvVPdxH/vnddf5dE627G3s8gyou3kexa6zQerQHvbKFX0wywNdA3HD2O/9STPv/r5mjXFUgw==", + "dev": true, + "license": "MIT", "dependencies": { - "ci-info": "^3.1.1", + "ci-info": "^4.0.0", "fullstore": "^3.0.0" }, "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/@putout/cli-validate-args": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@putout/cli-validate-args/-/cli-validate-args-1.1.1.tgz", - "integrity": "sha512-AczBS98YyvsDVxvvYjHGyIygFu3i/EJ0xsruU6MlytTuUiCFQIE/QQPDy1bcN5J2Y75BzSYncaYnVrEGcBjeeQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@putout/cli-validate-args/-/cli-validate-args-2.0.0.tgz", + "integrity": "sha512-/tl1XiBog6XMb1T9kalFerYU86sYsl6EtrlvGI5RVtlHOQdEEJAIPRxmX4vnKG3uoY5aVEkJOWzbPM5tsncmFQ==", "dev": true, "dependencies": { "fastest-levenshtein": "^1.0.12", - "just-kebab-case": "^1.1.0" + "just-kebab-case": "^4.2.0" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/@sinclair/typebox": { - "version": "0.25.24", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", - "integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==", - "dev": true + "version": "0.34.38", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", + "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stackoverflow/stacks": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.8.4.tgz", + "integrity": "sha512-FfA7Bw7a0AQrMw3/bG6G4BUrZ698F7Cdk6HkR9T7jdaufORkiX5d16wI4j4b5Sqm1FwkaZAF+ZSKLL1w0tAsew==", + "license": "MIT", + "dependencies": { + "@hotwired/stimulus": "^3.2.2", + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/@stackoverflow/stacks-icons": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/@stackoverflow/stacks-icons/-/stacks-icons-6.6.1.tgz", + "integrity": "sha512-upa2jajYTKAHfILFbPWMsml0nlh4fbIEb2V9SS0txjOJEoZE2oBnNJXbg29vShp7Nyn1VwrMjaraX63WkKT07w==", + "license": "MIT" }, "node_modules/@supertape/engine-loader": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@supertape/engine-loader/-/engine-loader-1.1.3.tgz", - "integrity": "sha512-5ilgEng0WBvMQjNJWQ/bnAA6HKgbLKxTya2C0RxFH0LYSN5faBVtgxjLDvTQ+5L+ZxjK/7ooQDDaRS1Mo0ga5Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@supertape/engine-loader/-/engine-loader-2.0.0.tgz", + "integrity": "sha512-1G2MmfZnSxx546omLPAVNgvG/iqOQZGiXHnjJ2JXKvuf2lpPdDRnNm5eLl81lvEG473zE9neX979TzeFcr3Dxw==", "dev": true, "dependencies": { "try-catch": "^3.0.0" }, "engines": { - "node": ">=14" + "node": ">=16" } }, "node_modules/@supertape/formatter-fail": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@supertape/formatter-fail/-/formatter-fail-3.0.2.tgz", - "integrity": "sha512-mSBnNprfLFmGvZkP+ODGroPLFCIN5BWE/06XaD5ghiTVWqek7eH8IDqvKyEduvuQu1O5tvQiaTwQsyxvikF+2w==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@supertape/formatter-fail/-/formatter-fail-4.0.0.tgz", + "integrity": "sha512-+isArOXmGkIqH14PQoq2WhJmSwO8rzpQnhurVMuBmC+kYB96R95kRdjo/KO9d9yP1KoSjum0kX94s0SwqlZ8yA==", "dev": true, + "license": "MIT", "dependencies": { - "@supertape/formatter-tap": "^3.0.3", + "@supertape/formatter-tap": "^4.0.0", "fullstore": "^3.0.0" }, "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/@supertape/formatter-json-lines": { @@ -268,43 +1003,90 @@ } }, "node_modules/@supertape/formatter-progress-bar": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@supertape/formatter-progress-bar/-/formatter-progress-bar-3.0.0.tgz", - "integrity": "sha512-rVFAQ21eApq3TQV8taFLNcCxcGZvvOPxQC63swdmHFCp+07Dt3tvC/aFxF35NLobc3rySasGSEuPucpyoPrjfg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@supertape/formatter-progress-bar/-/formatter-progress-bar-7.0.0.tgz", + "integrity": "sha512-JDCT86hFJkoaqE/KS8BQsRaYiy3ipMpf0j+o+vwQMcFYm0mgG35JwbotBMUQM7LFifh68bTqU4xuewy7kUS1EA==", "dev": true, + "license": "MIT", "dependencies": { - "chalk": "^4.1.0", - "ci-info": "^3.1.1", + "chalk": "^5.3.0", + "ci-info": "^4.0.0", "cli-progress": "^3.8.2", "fullstore": "^3.0.0", "once": "^1.4.0" }, "engines": { - "node": ">=14" + "node": ">=20" + } + }, + "node_modules/@supertape/formatter-progress-bar/node_modules/chalk": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz", + "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/@supertape/formatter-short": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@supertape/formatter-short/-/formatter-short-2.0.1.tgz", - "integrity": "sha512-zxFrZfCccFV+bf6A7MCEqT/Xsf0Elc3qa0P3jShfdEfrpblEcpSo0T/Wd9jFwc7uHA3ABgxgcHy7LNIpyrFTCg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@supertape/formatter-short/-/formatter-short-3.0.0.tgz", + "integrity": "sha512-lKiIMekxQgkF4YBj/IiFoRUQrF/Ow7D8zt9ZEBdHTkRys30vhRFn9557okECKGdpnAcSsoTHWwgikS/NPc3g/g==", "dev": true, + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=20" } }, "node_modules/@supertape/formatter-tap": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@supertape/formatter-tap/-/formatter-tap-3.0.3.tgz", - "integrity": "sha512-U5OuMotfYhGo9cZ8IgdAXRTH5Yy8yfLDZzYo1upTPTwlJJquKwtvuz7ptiB7BN3OFr5YakkDYlFxOYPcLo7urg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@supertape/formatter-tap/-/formatter-tap-4.0.0.tgz", + "integrity": "sha512-cupeiik+FeTQ24d0fihNdS901Ct720UhUqgtPl2DiLWadEIT/B8+TIB4MG60sTmaE8xclbCieanbS/I94CQTPw==", "dev": true, + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=20" + } + }, + "node_modules/@supertape/formatter-time": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@supertape/formatter-time/-/formatter-time-2.0.0.tgz", + "integrity": "sha512-5UPvVHwpg5ZJmz0nII2f5rBFqNdMxHQnBybetmhgkSDIZHb+3NTPz/VrDggZERWOGxmIf4NKebaA+BWHTBQMeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "ci-info": "^4.0.0", + "cli-progress": "^3.8.2", + "fullstore": "^3.0.0", + "once": "^1.4.0", + "timer-node": "^5.0.7" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@supertape/formatter-time/node_modules/chalk": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz", + "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/@supertape/operator-stub": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@supertape/operator-stub/-/operator-stub-3.0.0.tgz", - "integrity": "sha512-LZ6E4nSMDMbLOhvEZyeXo8wS5EBiAAffWrohb7yaVHDVTHr+xkczzPxinkvcOBhNuAtC0kVARdMbHg+HULmozA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@supertape/operator-stub/-/operator-stub-3.1.0.tgz", + "integrity": "sha512-jzC56u1k+3DLRo854+J6v/DP/4SjRV2mAqfR6qzsyaAocC9OFe7NHYQQMmlJ4cUJwgFjUh7AVnjFfC0Z0XuH+g==", "dev": true, "dependencies": { "@cloudcmd/stub": "^4.0.0" @@ -325,30 +1107,24 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.16.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.5.tgz", - "integrity": "sha512-seOA34WMo9KB+UA78qaJoCO20RJzZGVXQ5Sh6FWu0g/hfT44nKXnej3/tCQl7FL97idFpBhisLYCTB50S0EirA==", - "dev": true - }, - "node_modules/@types/node-fetch": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.3.tgz", - "integrity": "sha512-ETTL1mOEdq/sxUtgtOhKjyB2Irra4cjxksvcMUR5Zr4n+PxVhsCD9WS46oPbHL3et9Zde7CNRr+WUNlcHvsX+w==", + "version": "22.18.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz", + "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==", "dev": true, + "license": "MIT", "dependencies": { - "@types/node": "*", - "form-data": "^3.0.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, "node_modules/@types/react": { - "version": "18.2.20", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.20.tgz", - "integrity": "sha512-WKNtmsLWJM/3D5mG4U84cysVY31ivmyw85dE84fOCk5Hx78wezB/XEjVPWl2JTZ5FkEeaTJf+VgUAUn3PE7Isw==", + "version": "18.2.55", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.55.tgz", + "integrity": "sha512-Y2Tz5P4yz23brwm2d7jNon39qoAtMMmalOQv6+fEFt1mT+FcM3D841wDpoUvFXhaYenuROCy3FZYqdTjM7qVyA==", "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -356,32 +1132,27 @@ } }, "node_modules/@types/scheduler": { - "version": "0.16.3", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", - "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==" + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, - "dependencies": { - "event-target-shim": "^5.0.0" + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "bin": { + "acorn": "bin/acorn" }, "engines": { - "node": ">=6.5" + "node": ">=0.4.0" } }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", "engines": { - "node": ">= 0.6" + "node": ">=6" } }, "node_modules/ansi-regex": { @@ -411,29 +1182,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", - "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, "node_modules/as-table": { "version": "1.0.55", "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", @@ -443,32 +1191,27 @@ "printable-characters": "^1.0.42" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "node_modules/assert-never": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.3.0.tgz", + "integrity": "sha512-9Z3vxQ+berkL/JJo0dK+EY3Lp0s3NtSnP3VCLsh5HDcZPrh0M+KQRK5sWhUeyPPH+/RCxZqOxLMR+YC6vlviEQ==" }, - "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", - "dev": true, - "engines": { - "node": ">= 0.4" + "node_modules/babel-walk": { + "version": "3.0.0-canary-5", + "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", + "integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==", + "dependencies": { + "@babel/types": "^7.9.6" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">= 10.0.0" } }, - "node_modules/b4a": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", - "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==" - }, "node_modules/backtracker": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/backtracker/-/backtracker-3.3.2.tgz", - "integrity": "sha512-bXosLBp95xGE1kcWRnbG+e+Sw1xCKTT1GMdvaqEk9cGfBQoFPEvdI78fepKIJWFejV4NCl7OLUt8SNvYom/D/w==" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/backtracker/-/backtracker-4.0.0.tgz", + "integrity": "sha512-XG2ldN+WDRq9niJMnoZDjLLUnhDOQGhFZc6qZQotN59xj8oOa4KXSCu6YyZQawPqi6gG3HilGFt91zT6Hbdh1w==", + "license": "MIT" }, "node_modules/balanced-match": { "version": "1.0.2", @@ -495,30 +1238,18 @@ } ] }, - "node_modules/basic-auth": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", - "dependencies": { - "safe-buffer": "5.1.2" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/basic-auth/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, "node_modules/better-sqlite3": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-8.4.0.tgz", - "integrity": "sha512-NmsNW1CQvqMszu/CFAJ3pLct6NEFlNfuGM6vw72KHkjOD1UDnL96XNN1BMQc1hiHo8vE2GbOWQYIpZ+YM5wrZw==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.2.0.tgz", + "integrity": "sha512-eGbYq2CT+tos1fBwLQ/tkBt9J5M3JEHjku4hbvQUePCckkvVf14xWj+1m7dGoK81M/fOjFT7yM9UMeKT/+vFLQ==", "hasInstallScript": true, + "license": "MIT", "dependencies": { "bindings": "^1.5.0", - "prebuild-install": "^7.1.0" + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x" } }, "node_modules/bindings": { @@ -575,96 +1306,30 @@ "node": ">= 6" } }, - "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/c8": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/c8/-/c8-8.0.1.tgz", - "integrity": "sha512-EINpopxZNH1mETuI0DzRA4MZpAUH+IFiRhnmFD3vFr3vdrgxqi3VfE3KL0AIL+zDq8rC9bZqwM/VDmmoe04y7w==", + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", + "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", "dev": true, "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", + "@bcoe/v8-coverage": "^1.0.1", "@istanbuljs/schema": "^0.1.3", "find-up": "^5.0.0", - "foreground-child": "^2.0.0", + "foreground-child": "^3.1.1", "istanbul-lib-coverage": "^3.2.0", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.1.6", - "rimraf": "^3.0.2", - "test-exclude": "^6.0.0", + "test-exclude": "^7.0.1", "v8-to-istanbul": "^9.0.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1" @@ -673,19 +1338,15 @@ "c8": "bin/c8.js" }, "engines": { - "node": ">=12" - } - }, - "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "node": ">=18" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } } }, "node_modules/chalk": { @@ -704,6 +1365,11 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-parser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-4.0.0.tgz", + "integrity": "sha512-jWburCrDpd+aPopB7esjh/gLyZoHZS4C2xwwJlkTPyhhJdXG+FCG0P4qCOInvOd9yhiuAEJYlZsUMQ0JSK4ykw==" + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -722,9 +1388,9 @@ } }, "node_modules/ci-info": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", - "integrity": "sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", "dev": true, "funding": [ { @@ -732,6 +1398,7 @@ "url": "https://github.com/sponsors/sibiraj-s" } ], + "license": "MIT", "engines": { "node": ">=8" } @@ -741,6 +1408,7 @@ "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", "dev": true, + "license": "MIT", "dependencies": { "string-width": "^4.2.3" }, @@ -784,14 +1452,16 @@ } }, "node_modules/cloudstorm": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.8.3.tgz", - "integrity": "sha512-4c2rqFFvzM4P3pcnjnGUlYuyBjx/xnMew6imB0sFwmNLITLCTLYa3qGkrnhI1g/tM0fqg+Gr+EmDHiDZfEr9LQ==", + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.14.1.tgz", + "integrity": "sha512-x95WCKg818E1rE1Ru45NPD3RoIq0pg3WxwvF0GE7Eq07pAeLcjSRqM1lUmbmfjdOqZrWdSRYA1NETVZ8QhVrIA==", + "license": "MIT", "dependencies": { - "snowtransfer": "^0.8.3" + "discord-api-types": "^0.38.21", + "snowtransfer": "^0.15.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=22.0.0" } }, "node_modules/color": { @@ -831,46 +1501,13 @@ "simple-swizzle": "^0.2.2" } }, - "node_modules/colorette": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", - "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", - "dev": true - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "node_modules/constantinople": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", + "integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==", "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" + "@babel/parser": "^7.6.0", + "@babel/types": "^7.6.1" } }, "node_modules/convert-source-map": { @@ -879,18 +1516,11 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true }, - "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + "node_modules/cookie-es": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "license": "MIT" }, "node_modules/cross-env": { "version": "7.0.3", @@ -911,9 +1541,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "dependencies": { "path-key": "^3.1.0", @@ -924,10 +1554,19 @@ "node": ">= 8" } }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/data-uri-to-buffer": { "version": "2.0.2", @@ -935,14 +1574,6 @@ "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", "dev": true }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -957,35 +1588,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deep-equal": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.1.tgz", - "integrity": "sha512-lKdkdV6EOGoVn65XaOsPdH4rMxTZOnmFyuIkMjM1i5HHCbfjC97dawgTAy0deYNfuqUqW+Q5VrVaQYtUpSd6yQ==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.0", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.0", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.9" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -994,86 +1596,50 @@ "node": ">=4.0.0" } }, - "node_modules/define-properties": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz", - "integrity": "sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==", - "dev": true, - "dependencies": { - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" }, "node_modules/detect-libc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", - "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", "engines": { "node": ">=8" } }, - "node_modules/diff-sequences": { - "version": "29.4.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.4.3.tgz", - "integrity": "sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/discord-api-types": { - "version": "0.37.53", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.53.tgz", - "integrity": "sha512-N6uUgv50OyP981Mfxrrt0uxcqiaNr0BDaQIoqfk+3zM2JpZtwU9v7ce1uaFAP53b2xSDvcbrk80Kneui6XJgGg==" - }, - "node_modules/discord-markdown": { - "version": "2.4.1", - "resolved": "git+https://git.sr.ht/~cadence/nodejs-discord-markdown#abc56d544072a1dc5624adfea455b0e902adf7b3", + "version": "0.38.22", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.22.tgz", + "integrity": "sha512-2gnYrgXN3yTlv2cKBISI/A8btZwsSZLwKpIQXeI1cS8a7W7wP3sFVQOm3mPuuinTD8jJCKGPGNH399zE7Un1kA==", "license": "MIT", - "dependencies": { - "simple-markdown": "^0.7.2" - } + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/doctypes": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", + "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==" }, "node_modules/domino": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.6.tgz", "integrity": "sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ==" }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -1081,14 +1647,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -1097,10 +1655,41 @@ "once": "^1.4.0" } }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/enquirer/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/enquirer/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-5.0.0.tgz", + "integrity": "sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==", "engines": { "node": ">=0.12" }, @@ -1108,26 +1697,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -1137,37 +1706,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1176,89 +1714,6 @@ "node": ">=6" } }, - "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.1", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.5.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.11.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/express/node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.1", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/express/node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" - }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -1289,23 +1744,6 @@ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" }, - "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1322,56 +1760,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dev": true, - "dependencies": { - "is-callable": "^1.1.3" - } + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true }, "node_modules/foreground-child": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", - "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, + "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^3.0.2" + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" }, "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" + "node": ">=14" }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "engines": { - "node": ">= 0.6" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/fs-constants": { @@ -1379,12 +1788,6 @@ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, "node_modules/fullstore": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/fullstore/-/fullstore-3.0.0.tgz", @@ -1395,14 +1798,9 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -1417,18 +1815,10 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-intrinsic": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", - "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", - "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/get-relative-path": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-relative-path/-/get-relative-path-1.0.2.tgz", + "integrity": "sha512-dGkopYfmB4sXMTcZslq5SojEYakpdCSj/SVSHLhv7D6RBHzvDtd/3Q8lTEOAhVKxPPeAHu/YYkENbbz3PaH+8w==" }, "node_modules/get-source": { "version": "2.0.12", @@ -1440,65 +1830,47 @@ "source-map": "^0.6.1" } }, - "node_modules/giframe": { - "version": "0.3.0", - "resolved": "git+ssh://git@github.com/cloudrac3r/giframe.git#1630f4d3b2bf5acd197409c85edd11e0da72d0a1", - "license": "MIT" - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" }, "node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, + "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": ">=12" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, + "node_modules/h3": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.4.tgz", + "integrity": "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==", + "license": "MIT", "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "cookie-es": "^1.2.2", + "crossws": "^0.3.5", + "defu": "^6.1.4", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.2", + "radix3": "^1.1.2", + "ufo": "^1.6.1", + "uncrypto": "^0.1.3" } }, "node_modules/has-flag": { @@ -1510,83 +1882,46 @@ "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", "dev": true, "dependencies": { - "get-intrinsic": "^1.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.2" + "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, "node_modules/heatsync": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.4.1.tgz", - "integrity": "sha512-cRzLwnKnJ5O4dQWXiJyFp4myKY8lGfK+49/SbPsvnr3pf2PNG1Xh8pPono303cjJeFpaPSTs609mQH1xhPVyzA==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.8.2.tgz", + "integrity": "sha512-zO5ivWP1NYoYmngdqVxzeQGX2Q68rfLkXKbO8Dhcguj5eS2eBDVpcWPh3+KCQagM7xYP5QVzvrUryWDu4mt6Eg==", + "license": "MIT", "dependencies": { - "backtracker": "3.3.2" + "backtracker": "^4.0.0" + }, + "engines": { + "node": ">=14.6.0" } }, + "node_modules/html-es6cape": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-es6cape/-/html-es6cape-2.0.2.tgz", + "integrity": "sha512-utzhH8rq2VABdW1LsPdv5tmxeMNOtP83If0jKCa79xPBgLWfcMvdf9K+EZoxJ5P7KioCxTs6WBnSDWLQHJ2lWA==" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } + "node_modules/htmx.org": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz", + "integrity": "sha512-7ythjYneGSk3yCHgtCnQeaoF+D+o7U2LF37WU3O0JYv3gTZSicdEFiI/Ai/NJyC5ZpYJWMpUb11OC5Lr6AfAqA==", + "license": "0BSD" }, "node_modules/ieee754": { "version": "1.2.1", @@ -1607,16 +1942,6 @@ } ] }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1627,56 +1952,13 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, - "node_modules/internal-slot": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", - "integrity": "sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.0", - "has": "^1.0.3", - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", - "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/brc-dd" } }, "node_modules/is-arrayish": { @@ -1684,71 +1966,25 @@ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" }, - "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, - "dependencies": { - "has-bigints": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-core-module": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.0.tgz", - "integrity": "sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "dev": true, "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dev": true, + "node_modules/is-expression": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", + "integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==", "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "acorn": "^7.1.1", + "object-assign": "^4.1.1" } }, "node_modules/is-fullwidth-code-point": { @@ -1760,144 +1996,6 @@ "node": ">=8" } }, - "node_modules/is-map": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", - "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", - "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", - "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", - "dev": true, - "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", - "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", - "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1940,45 +2038,54 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jest-diff": { - "version": "29.5.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.5.0.tgz", - "integrity": "sha512-LtxijLLZBduXnHSniy0WMdaHjmQnt3g5sa16W4p0HqukYTTsyTW3GD1q41TyGl5YFXj/5B2U6dlh5FM1LIMgxw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.5.tgz", + "integrity": "sha512-1UIqE9PoEKaHcIKvq2vbibrCog4Y8G0zmOxgQUVEiTqwR5hJVMCoDsN1vFvI5JvwD37hjueZ1C4l2FyGnfpE0A==", "dev": true, + "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.4.3", - "jest-get-type": "^29.4.3", - "pretty-format": "^29.5.0" + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "pretty-format": "30.0.5" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-get-type": { - "version": "29.4.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.4.3.tgz", - "integrity": "sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg==", + "node_modules/js-stringify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", + "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==" + }, + "node_modules/json-with-bigint": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.4.4.tgz", + "integrity": "sha512-AhpYAAaZsPjU7smaBomDt1SOQshi9rEm6BlTbfVwsG1vNmeHKtEedJi62sHZzJTyKNtwzmNnrsd55kjwJ7054A==", "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } + "license": "MIT" }, "node_modules/just-kebab-case": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/just-kebab-case/-/just-kebab-case-1.1.0.tgz", - "integrity": "sha512-QkuwuBMQ9BQHMUEkAtIA4INLrkmnnveqlFB1oFi09gbU0wBdZo6tTnyxNWMR84zHxBuwK7GLAwqN8nrvVxOLTA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/just-kebab-case/-/just-kebab-case-4.2.0.tgz", + "integrity": "sha512-p2BdO7o4BI+pMun3J+dhaOfYan5JsZrw9wjshRjkWY9+p+u+kKSMhNWYnot2yHDR9CSahZ9iT3dcqJ+V72qHMw==", "dev": true }, "node_modules/locate-path": { @@ -1997,14 +2104,12 @@ } }, "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", + "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "license": "ISC", "engines": { - "node": ">=10" + "node": "20 || >=22" } }, "node_modules/make-dir": { @@ -2022,52 +2127,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/matrix-appservice": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/matrix-appservice/-/matrix-appservice-2.0.0.tgz", - "integrity": "sha512-HCIuJ5i0YuO8b0dMyGe5dqlsE4f3RzHU0MuMg/2gGAZ4HL3r7aSWOFbyIWStSSUrk1qCa9Eml0i4EnEi0pOtdA==", - "dependencies": { - "body-parser": "^1.19.0", - "express": "^4.18.1", - "js-yaml": "^4.1.0", - "morgan": "^1.10.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2099,15 +2158,19 @@ } }, "node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -2118,12 +2181,13 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mixin-deep": { - "version": "3.0.0", - "resolved": "git+ssh://git@github.com/cloudrac3r/mixin-deep.git#2dd70d6b8644263f7ed2c1620506c9eb3f11d32a", - "license": "MIT", + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, "engines": { - "node": ">=6" + "node": ">=16 || 14 >=14.17" } }, "node_modules/mkdirp-classic": { @@ -2131,50 +2195,11 @@ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" }, - "node_modules/morgan": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", - "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", - "dependencies": { - "basic-auth": "~2.0.1", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-finished": "~2.3.0", - "on-headers": "~1.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/morgan/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, "node_modules/napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/node-abi": { "version": "3.40.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.40.0.tgz", @@ -2186,98 +2211,18 @@ "node": ">=10" } }, - "node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==" - }, - "node_modules/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-is": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", - "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { + "node_modules/node-mock-http": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.2.tgz", + "integrity": "sha512-zWaamgDUdo9SSLw47we78+zYw/bDr5gH8pH7oRRs8V3KmBtu8GLgGIbV2p/gRPd3LWpEOpjQj7X1FOU3VFMJ8g==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "engines": { - "node": ">= 0.8" + "node": ">=0.10.0" } }, "node_modules/once": { @@ -2318,13 +2263,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "engines": { - "node": ">= 0.8" - } + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "dev": true }, "node_modules/path-exists": { "version": "4.0.0", @@ -2335,15 +2278,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2359,10 +2293,28 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, - "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/peek-readable": { "version": "4.1.0", @@ -2376,14 +2328,6 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/pngjs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", - "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", - "engines": { - "node": ">=14.19.0" - } - }, "node_modules/prebuild-install": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", @@ -2415,17 +2359,18 @@ "integrity": "sha512-dLbWOa4xBn+qeWeIF60qRoB6Pk2jX5P3DIVgOQyMyvBpu931Q+8dXz8X0snJiFkQdohDDLnZQECjzsAj75hgZQ==" }, "node_modules/pretty-format": { - "version": "29.5.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.5.0.tgz", - "integrity": "sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/schemas": "^29.4.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/pretty-format/node_modules/ansi-styles": { @@ -2433,6 +2378,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -2446,27 +2392,66 @@ "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", "dev": true }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, - "engines": { - "node": ">= 0.6.0" + "node_modules/pug-attrs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz", + "integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==", + "dependencies": { + "constantinople": "^4.0.1", + "js-stringify": "^1.0.2", + "pug-runtime": "^3.0.0" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "node_modules/pug-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz", + "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==" + }, + "node_modules/pug-linker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz", + "integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==", "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0" } }, + "node_modules/pug-load": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz", + "integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==", + "dependencies": { + "object-assign": "^4.1.1", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz", + "integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==", + "dependencies": { + "pug-error": "^2.0.0", + "token-stream": "1.0.0" + } + }, + "node_modules/pug-runtime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz", + "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==" + }, + "node_modules/pug-strip-comments": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz", + "integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==", + "dependencies": { + "pug-error": "^2.0.0" + } + }, + "node_modules/pug-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz", + "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==" + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -2476,46 +2461,11 @@ "once": "^1.3.1" } }, - "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/queue-tick": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", - "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" }, "node_modules/rc": { "version": "1.2.8", @@ -2535,29 +2485,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/re-emitter/-/re-emitter-1.1.4.tgz", "integrity": "sha512-C0SIXdXDSus2yqqvV7qifnb4NoWP7mEBXJq3axci301mXHCZb8Djwm4hrEZo4UeXRaEnfjH98uQ8EBppk2oNWA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - }, - "node_modules/readable-stream": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.4.2.tgz", - "integrity": "sha512-Lk/fICSyIhodxy1IDK2HazkeGjSmezAWX2egdtJnYhtzKEsBPJowlI6F6LPb5tqIQILrMbx22S5o3GuJavPusA==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } + "license": "MIT" }, "node_modules/readable-web-to-node-stream": { "version": "3.0.2", @@ -2587,23 +2523,6 @@ "node": ">= 6" } }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz", - "integrity": "sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "functions-have-names": "^1.2.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2614,12 +2533,12 @@ } }, "node_modules/resolve": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", - "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "dev": true, "dependencies": { - "is-core-module": "^2.11.0", + "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -2630,63 +2549,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/runes": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/runes/-/runes-0.4.3.tgz", @@ -2714,18 +2576,10 @@ } ] }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "bin": { "semver": "bin/semver.js" }, @@ -2733,93 +2587,43 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "dependencies": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, "node_modules/sharp": { - "version": "0.32.6", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", - "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { "color": "^4.2.3", - "detect-libc": "^2.0.2", - "node-addon-api": "^6.1.0", - "prebuild-install": "^7.1.1", - "semver": "^7.5.4", - "simple-get": "^4.0.1", - "tar-fs": "^3.0.4", - "tunnel-agent": "^0.6.0" + "detect-libc": "^2.0.3", + "semver": "^7.6.3" }, "engines": { - "node": ">=14.15.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/tar-fs": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", - "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", - "dependencies": { - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - } - }, - "node_modules/sharp/node_modules/tar-stream": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", - "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" } }, "node_modules/shebang-command": { @@ -2843,25 +2647,18 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -2922,29 +2719,15 @@ } }, "node_modules/snowtransfer": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.8.3.tgz", - "integrity": "sha512-0X6NLFBUKppYT5VH/mVQNGX+ufv0AndunZC84MqGAR/3rfTIGQblgGJlHlDQbeCytlXdMpgRHIGQnBFlE094NQ==", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.15.0.tgz", + "integrity": "sha512-kEDGKtFiH5nSkHsDZonEUuDx99lUasJoZ7AGrgvE8HzVG59vjvqc//C+pjWj4DuJqTj4Q+Z1L/M/MYNim8F2VA==", + "license": "MIT", "dependencies": { - "discord-api-types": "^0.37.47", - "form-data": "^4.0.0", - "undici": "^5.22.1" + "discord-api-types": "^0.38.21" }, "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/snowtransfer/node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" + "node": ">=16.15.0" } }, "node_modules/source-map": { @@ -2956,16 +2739,14 @@ "node": ">=0.10.0" } }, - "node_modules/split": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", - "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "dev": true, - "dependencies": { - "through": "2" - }, + "license": "ISC", "engines": { - "node": "*" + "node": ">= 10.x" } }, "node_modules/stacktracey": { @@ -2978,26 +2759,6 @@ "get-source": "^2.0.12" } }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/stop-iteration-iterator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", - "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", - "dev": true, - "dependencies": { - "internal-slot": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/stream-head": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/stream-head/-/stream-head-2.0.2.tgz", @@ -3022,23 +2783,6 @@ "node": ">=10" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/streamx": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.1.tgz", - "integrity": "sha512-fQMzy2O/Q47rgwErk/eGeLu/roaFWV0jVsogDmrszM9uIw8L5OA+t+V93MgYlufNptfjmYR1tOMWhei/Eh7TQA==", - "dependencies": { - "fast-fifo": "^1.1.0", - "queue-tick": "^1.0.1" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -3061,6 +2805,45 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string-width/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3083,9 +2866,9 @@ } }, "node_modules/strip-ansi": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", - "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, "dependencies": { "ansi-regex": "^6.0.1" @@ -3097,6 +2880,30 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -3122,40 +2929,126 @@ } }, "node_modules/supertape": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/supertape/-/supertape-8.3.0.tgz", - "integrity": "sha512-dcMylmkr1Mctr5UBCrlvZynuBRuLvlkWJLGXdL/PcI41BERnObO+kV0PeZhH5n6lwVnvK2xfvZyN32WIAPf/tw==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/supertape/-/supertape-11.3.0.tgz", + "integrity": "sha512-2LP36xLtxsb3bBYrfvWIilhWpA/vs7/vIgElpsqEhZZ0vcOAMlhMIxH6eHAl5u9KcxGD28IrJrw8lREqeMtZeQ==", "dev": true, + "license": "MIT", "dependencies": { "@cloudcmd/stub": "^4.0.0", - "@putout/cli-keypress": "^1.0.0", - "@putout/cli-validate-args": "^1.0.1", - "@supertape/engine-loader": "^1.0.0", - "@supertape/formatter-fail": "^3.0.0", + "@putout/cli-keypress": "^3.0.0", + "@putout/cli-validate-args": "^2.0.0", + "@supertape/engine-loader": "^2.0.0", + "@supertape/formatter-fail": "^4.0.0", "@supertape/formatter-json-lines": "^2.0.0", - "@supertape/formatter-progress-bar": "^3.0.0", - "@supertape/formatter-short": "^2.0.0", - "@supertape/formatter-tap": "^3.0.0", + "@supertape/formatter-progress-bar": "^7.0.0", + "@supertape/formatter-short": "^3.0.0", + "@supertape/formatter-tap": "^4.0.0", + "@supertape/formatter-time": "^2.0.0", "@supertape/operator-stub": "^3.0.0", "cli-progress": "^3.8.2", - "deep-equal": "^2.0.3", + "flatted": "^3.3.1", "fullstore": "^3.0.0", - "glob": "^8.0.3", - "jest-diff": "^29.0.1", + "glob": "^11.0.1", + "jest-diff": "^30.0.3", + "json-with-bigint": "^3.4.4", "once": "^1.4.0", "resolve": "^1.17.0", "stacktracey": "^2.1.7", "strip-ansi": "^7.0.0", "try-to-catch": "^3.0.0", "wraptile": "^3.0.0", - "yargs-parser": "^21.0.0" + "yargs-parser": "^22.0.0" }, "bin": { - "supertape": "bin/supertape.mjs", - "tape": "bin/supertape.mjs" + "supertape": "bin/tracer.mjs", + "tape": "bin/tracer.mjs" }, "engines": { - "node": ">=16" + "node": ">=20" + } + }, + "node_modules/supertape/node_modules/glob": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supertape/node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supertape/node_modules/minimatch": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supertape/node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supertape/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/supports-color": { @@ -3182,42 +3075,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tap-dot": { - "version": "2.0.0", - "resolved": "git+ssh://git@github.com/cloudrac3r/tap-dot.git#9dd7750ececeae3a96afba91905be812b6b2cc2d", - "integrity": "sha512-SLg6KF3cSkKII+5hA/we9FjnMCrL5uk0wYap7RXD9KJziy7xqZolvEOamt3CJlm5LSzRXIGblm3nmhY/EBE3AA==", - "dev": true, - "license": "MIT", - "dependencies": { - "colorette": "^1.0.5", - "tap-out": "github:cloudrac3r/tap-out#1b4ec6084aedb9f44ccaa0c7185ff9bfd83da771" - }, - "bin": { - "tap-dot": "bin/dot" - } - }, - "node_modules/tap-out": { - "version": "3.2.1", - "resolved": "git+ssh://git@github.com/cloudrac3r/tap-out.git#1b4ec6084aedb9f44ccaa0c7185ff9bfd83da771", - "integrity": "sha512-55eUSaX5AeEOqJMRlj9XSqUlLV/yYPOPeC3kOFqjmorq6/jlH5kIeqpgLNW5PlPEAuggzYREYYXqrN8E37ZPfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "re-emitter": "1.1.4", - "readable-stream": "^4.3.0", - "split": "^1.0.1" - }, - "bin": { - "tap-in": "bin/tap-in.js" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "license": "MIT", "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -3254,67 +3116,19 @@ } }, "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", "dev": true, "dependencies": { "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "glob": "^10.4.1", + "minimatch": "^9.0.4" }, "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true - }, "node_modules/through2": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", @@ -3336,14 +3150,26 @@ "node": ">= 6" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "node_modules/timer-node": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/timer-node/-/timer-node-5.0.9.tgz", + "integrity": "sha512-zXxCE/5/YDi0hY9pygqgRqjRbrFRzigYxOudG0I3syaqAAmX9/w9sxex1bNFCN6c1S66RwPtEIJv65dN+1psew==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", "engines": { - "node": ">=0.6" + "node": ">=4" } }, + "node_modules/token-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", + "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==" + }, "node_modules/token-types": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", @@ -3360,11 +3186,6 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, "node_modules/try-catch": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/try-catch/-/try-catch-3.0.1.tgz", @@ -3382,6 +3203,13 @@ "node": ">=6" } }, + "node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "license": "0BSD", + "optional": true + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -3393,58 +3221,36 @@ "node": "*" } }, - "node_modules/turndown": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.1.2.tgz", - "integrity": "sha512-ntI9R7fcUKjqBP6QU8rBK2Ehyt8LAzt3UBT9JR9tgo6GtuKvyUzpayWmeMKJw1DPdXzktvtIT8m2mVXz+bL/Qg==", - "dependencies": { - "domino": "^2.1.6" - } + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "license": "MIT" }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" }, - "node_modules/undici": { - "version": "5.22.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.22.1.tgz", - "integrity": "sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==", - "dependencies": { - "busboy": "^1.6.0" - }, - "engines": { - "node": ">=14.0" - } + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } + "node_modules/uqr": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz", + "integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==", + "license": "MIT" }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/v8-to-istanbul": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz", @@ -3459,26 +3265,12 @@ "node": ">=10.12.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", "engines": { - "node": ">= 0.8" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" + "node": ">=0.10.0" } }, "node_modules/which": { @@ -3496,55 +3288,18 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, + "node_modules/with": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz", + "integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==", "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", - "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", - "dev": true, - "dependencies": { - "is-map": "^2.0.1", - "is-set": "^2.0.1", - "is-weakmap": "^2.0.1", - "is-weakset": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", - "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", - "dev": true, - "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0", - "is-typed-array": "^1.1.10" + "@babel/parser": "^7.9.6", + "@babel/types": "^7.9.6", + "assert-never": "^1.2.1", + "babel-walk": "3.0.0-canary-5" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 10.0.0" } }, "node_modules/wrap-ansi": { @@ -3564,6 +3319,48 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3597,9 +3394,9 @@ "dev": true }, "node_modules/xxhash-wasm": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.0.2.tgz", - "integrity": "sha512-ibF0Or+FivM9lNrg+HGJfVX8WJqgo+kCLDc4vx6xMeTce7Aj+DLttKbxxRR/gNLSAelRc1omAPlJ77N/Jem07A==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==" }, "node_modules/y18n": { "version": "5.0.8", @@ -3610,11 +3407,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -3653,6 +3445,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.5.tgz", + "integrity": "sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 6a9deea..2fb21f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "out-of-your-element", - "version": "1.1.1", + "version": "3.1.0", "description": "A bridge between Matrix and Discord", "main": "index.js", "repository": { @@ -14,42 +14,56 @@ ], "author": "Cadence, PapiOphidian", "license": "AGPL-3.0-or-later", + "engines": { + "node": ">=20" + }, "dependencies": { - "@chriscdn/promise-semaphore": "^2.0.1", - "better-sqlite3": "^8.3.0", + "@chriscdn/promise-semaphore": "^3.0.1", + "@cloudrac3r/discord-markdown": "^2.6.6", + "@cloudrac3r/giframe": "^0.4.3", + "@cloudrac3r/html-template-tag": "^5.0.1", + "@cloudrac3r/in-your-element": "^1.1.1", + "@cloudrac3r/mixin-deep": "^3.0.1", + "@cloudrac3r/pngjs": "^7.0.3", + "@cloudrac3r/pug": "^4.0.4", + "@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.8.0", - "discord-markdown": "git+https://git.sr.ht/~cadence/nodejs-discord-markdown#abc56d544072a1dc5624adfea455b0e902adf7b3", - "entities": "^4.5.0", - "giframe": "github:cloudrac3r/giframe#v0.4.1", - "heatsync": "^2.4.1", - "js-yaml": "^4.1.0", - "matrix-appservice": "^2.0.0", - "minimist": "^1.2.8", - "mixin-deep": "github:cloudrac3r/mixin-deep#v3.0.0", - "node-fetch": "^2.6.7", - "pngjs": "^7.0.0", + "cloudstorm": "^0.14.0", + "discord-api-types": "^0.38.19", + "domino": "^2.1.6", + "enquirer": "^2.4.1", + "entities": "^5.0.0", + "get-relative-path": "^1.0.2", + "h3": "^1.15.1", + "heatsync": "^2.7.2", + "htmx.org": "^2.0.4", + "lru-cache": "^11.0.2", "prettier-bytes": "^1.0.4", - "sharp": "^0.32.6", - "snowtransfer": "^0.8.0", + "sharp": "^0.33.4", + "snowtransfer": "^0.15.0", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", - "turndown": "^7.1.2", - "xxhash-wasm": "^1.0.2" + "uqr": "^0.1.2", + "xxhash-wasm": "^1.0.2", + "zod": "^4.0.17" }, "devDependencies": { - "@types/node": "^18.16.0", - "@types/node-fetch": "^2.6.3", - "c8": "^8.0.1", + "@cloudrac3r/tap-dot": "^2.0.3", + "@types/node": "^22.17.1", + "c8": "^10.1.2", "cross-env": "^7.0.3", - "discord-api-types": "^0.37.53", - "supertape": "^8.3.0", - "tap-dot": "github:cloudrac3r/tap-dot#9dd7750ececeae3a96afba91905be812b6b2cc2d" + "supertape": "^11.3.0" }, "scripts": { + "start": "node --enable-source-maps start.js", + "setup": "node --enable-source-maps scripts/setup.js", "addbot": "node addbot.js", - "test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js | tap-dot", - "test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap test/test.js -- --slow | tap-dot", - "cover": "c8 --skip-full -x db/migrations -x matrix/file.js -x matrix/api.js -x matrix/mreq.js -r html -r text supertape --no-check-assertions-count --format fail test/test.js -- --slow" + "test": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js | tap-dot", + "test-slow": "cross-env FORCE_COLOR=true supertape --no-check-assertions-count --format tap --no-worker test/test.js -- --slow | tap-dot", + "cover": "c8 -o test/coverage --skip-full -x db/migrations -x src/matrix/file.js -x src/matrix/api.js -x src/d2m/converters/rlottie-wasm.js -r html -r text supertape --no-check-assertions-count --format fail --no-worker test/test.js -- --slow" } } diff --git a/readme.md b/readme.md index 5c97745..8c9fc90 100644 --- a/readme.md +++ b/readme.md @@ -2,176 +2,43 @@ -Modern Matrix-to-Discord appservice bridge. +Modern Matrix-to-Discord appservice bridge, created by [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe) -Created by [@cadence:cadence.moe](https://matrix.to/#/@cadence:cadence.moe) // Discuss in [#out-of-your-element:cadence.moe](https://matrix.to/#/#out-of-your-element:cadence.moe) +[![Releases](https://img.shields.io/gitea/v/release/cadence/out-of-your-element?gitea_url=https%3A%2F%2Fgitdab.com&style=plastic&color=green)](https://gitdab.com/cadence/out-of-your-element/releases) [![Discuss on Matrix](https://img.shields.io/badge/discuss-%23out--of--your--element-white?style=plastic)](https://matrix.to/#/#out-of-your-element:cadence.moe) -## Docs - -This readme has the most important info. The rest is [in the docs folder.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs) +![](https://cadence.moe/i/f42a3f) ## Why a new bridge? * Modern: Supports new Discord features like replies, threads and stickers, and new Matrix features like edits, spaces and space membership. -* Efficient: Special attention has been given to memory usage, database indexes, disk footprint, runtime algorithms, and queries to the homeserver. -* Reliable: Any errors on either side are notified on Matrix and can be retried. -* Tested: A test suite and code coverage make sure all the core logic works. +* Efficient: Special attention has been given to memory usage, database indexes, disk footprint, runtime algorithms, and queries to the homeserver. [Efficiency details.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/developer-orientation.md) +* Reliable: Any errors on either side are notified on Matrix and can be retried. Messages sent during bridge downtime will still be bridged after it comes back up. +* Tested: A test suite and code coverage make sure all the logic and special cases work. * Simple development: No build step (it's JavaScript, not TypeScript), minimal/lightweight dependencies, and abstraction only where necessary so that less background knowledge is required. No need to learn about Intents or library functions. * No locking algorithm: Other bridges use a locking algorithm which is a source of frequent bugs. This bridge avoids the need for one. * Latest API: Being on the latest Discord API version lets it access all features, without the risk of deprecated API versions being removed. ## What works? -Most features you'd expect in both directions, plus a little extra spice: +Most features you'd expect in both directions: messages, edits, deletions, formatting (including spoilers), reactions, custom emojis, custom emoji reactions, mentions, channel mentions, replies, threads, stickers (all formats: PNG, APNG, GIF, Lottie), attachments, spoiler attachments (compatible with most clients), embeds, URL previews, presence, discord.com hyperlinks, and more. -* Messages -* Edits -* Deletions -* Text formatting, including spoilers -* Reactions -* Mentions -* Replies -* Threads -* Stickers (all formats: PNG, APNG, GIF, and Lottie) -* Attachments -* Spoiler attachments -* Embeds -* Guild-Space details syncing -* Channel-Room details syncing -* Custom emoji list syncing -* Custom emojis in messages -* Custom room names/avatars can be applied on Matrix-side -* Larger files from Discord are linked instead of reuploaded to Matrix +Metadata is also synced: people's names, avatars, usernames; channel names, icons, topics; spaces containing rooms; custom emoji lists. Syncing Matrix rooms, room icons, and topics is optional: you can keep them different from the Discord ones if you prefer. + +I've also added some interesting features that I haven't seen in any other bridge: + +* Members using the PluralKit bot each get their own persistent accounts +* Replies from PluralKit members are restyled into native Matrix replies +* Simulated user accounts are named @the_persons_username rather than @112233445566778899 +* Matrix custom emojis from private rooms are still visible on Discord as a sprite sheet +* To save space, larger files from Discord are linked instead of reuploaded to Matrix (links don't expire) For more information about features, [see the user guide.](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/user-guide.md) ## Caveats * This bridge is not designed for puppetting. -* Direct Messaging is not supported yet. +* Direct Messaging is not supported until I figure out a good way of doing it. -## Efficiency details +## Get started! -Using WeatherStack as a thin layer between the bridge application and the Discord API lets us control exactly what data is cached. Only necessary information is cached. For example, member data, user data, message content, and past edits are never stored in memory. This keeps the memory usage low and also prevents it ballooning in size over the bridge's runtime. - -The bridge uses a small SQLite database to store relationships like which Discord messages correspond to which Matrix messages. This is so the bridge knows what to edit when some message is edited on Discord. Using `without rowid` on the database tables stores the index and the data in the same B-tree. Since Matrix and Discord's internal IDs are quite long, this vastly reduces storage space because those IDs do not have to be stored twice separately. Some event IDs are actually stored as xxhash integers to reduce storage requirements even more. On my personal instance of OOYE, every 100,000 messages require 16.1 MB of storage space in the SQLite database. - -Only necessary data and columns are queried from the database. We only contact the homeserver API if the database doesn't contain what we need. - -File uploads (like avatars from bridged members) are checked locally and deduplicated. Only brand new files are uploaded to the homeserver. This saves loads of space in the homeserver's media repo, especially for Synapse. - -Switching to [WAL mode](https://www.sqlite.org/wal.html) could improve your database access speed even more. Run `node scripts/wal.js` if you want to switch to WAL mode. - -# Setup - -If you get stuck, you're welcome to message @cadence:cadence.moe to ask for help setting up OOYE! - -You'll need: - -* Administrative access to a homeserver -* Discord bot - -Follow these steps: - -1. [Get Node.js version 18 or later](https://nodejs.org/en/download/releases) (the version is required by the matrix-appservice dependency) - -1. Clone this repo and checkout a specific tag. (Development happens on main. Stabler versions are tagged.) - -1. Install dependencies: `npm install --save-dev` (omit --save-dev if you will not run the automated tests) - -1. Copy `config.example.js` to `config.js` and fill in Discord token. - -1. Copy `registration.example.yaml` to `registration.yaml` and fill in bracketed values. You could generate each hex string with `dd if=/dev/urandom bs=32 count=1 2> /dev/null | basenc --base16 | dd conv=lcase 2> /dev/null`. Register the registration in Synapse's `homeserver.yaml` through the usual appservice installation process, then restart Synapse. - -1. Run `node scripts/seed.js` to check your setup and set the bot's initial state. You only need to run this once ever. -1. Make sure the tests work by running `npm t` - -1. Start the bridge: `node start.js` - -1. Add the bot to a server - use any *one* of the following commands for an invite link: - * (in the REPL) `addbot` - * (in a chat) `//addbot` - * $ `node addbot.js` - * $ `npm run addbot` - * $ `./addbot.sh` - -# Development setup - -* Be sure to install dependencies with `--save-dev` so you can run the tests. -* Any files you change will automatically be reloaded, except for `stdin.js` and `d2m/discord-*.js`. -* If developing on a different computer to the one running the homeserver, use SSH port forwarding so that Synapse can connect on its `localhost:6693` to reach the running bridge on your computer. Example: `ssh -T -v -R 6693:localhost:6693 me@matrix.cadence.moe` -* I recommend developing in Visual Studio Code so that the JSDoc x TypeScript annotation comments work. I don't know which other editors or language servers support annotations and type inference. - -## Repository structure - - . - * Run this to start the bridge: - ├── start.js - * Runtime configuration, like tokens and user info: - ├── config.js - ├── registration.yaml - * The bridge's SQLite database is stored here: - ├── db - │   ├── *.sql, *.db - │   * Migrations change the database schema when you update to a newer version of OOYE: - │   └── migrations - │       └── *.sql, *.js - * Discord-to-Matrix bridging: - ├── d2m - │   * Execute actions through the whole flow, like sending a Discord message to Matrix: - │   ├── actions - │   │   └── *.js - │   * Convert data from one form to another without depending on bridge state. Called by actions: - │   ├── converters - │   │   └── *.js - │   * Making Discord work: - │   ├── discord-*.js - │   * Listening to events from Discord and dispatching them to the correct `action`: - │   └── event-dispatcher.js - ├── discord - │   └── discord-command-handler.js - * Matrix-to-Discord bridging: - ├── m2d - │   * Execute actions through the whole flow, like sending a Matrix message to Discord: - │   ├── actions - │   │   └── *.js - │   * Convert data from one form to another without depending on bridge state. Called by actions: - │   ├── converters - │   │   └── *.js - │   * Listening to events from Matrix and dispatching them to the correct `action`: - │   └── event-dispatcher.js - * We aren't using the matrix-js-sdk, so here are all the functions for the Matrix C-S and Appservice APIs: - ├── matrix - │   └── *.js - * Various files you can run once if you need them. - ├── scripts - │   * First time running a new bridge? Run this file to plant a seed, which will flourish into state for the bridge: - │   ├── seed.js - │   * Hopefully you won't need the rest of these. Code quality varies wildly. - │   └── *.js - * You are here! :) - └── readme.md - -## Dependency justification - -(deduped transitive dependency count) dependency name: explanation - -* (0) @chriscdn/promise-semaphore: It does what I want! I like it! -* (42) better-sqlite3: SQLite3 is the best database, and this is the best library for it. Really! I love it. -* (1) chunk-text: It does what I want. -* (0) cloudstorm: Discord gateway library with bring-your-own-caching that I trust. -* (8) snowtransfer: Discord API library with bring-your-own-caching that I trust. -* (1) discord-markdown: This is my fork! I make sure it does what I want. -* (0) giframe: This is my fork! It should do what I want. -* (1) heatsync: Module hot-reloader that I trust. -* (0) entities: Looks fine. No dependencies. -* (1) js-yaml: It seems to do what I want, and it's already pulled in by matrix-appservice. -* (70) matrix-appservice: I wish it didn't pull in express :( -* (0) minimist: It's already pulled in by better-sqlite3->prebuild-install -* (0) mixin-deep: This is my fork! It fixes a bug in regular mixin-deep. -* (3) node-fetch@2: I like it and it does what I want. -* (0) pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs. -* (0) prettier-bytes: It does what I want and has no dependencies. -* (51) sharp: Jimp has fewer dependencies, but sharp is faster. -* (0) try-to-catch: Not strictly necessary, but it does what I want and has no dependencies. -* (1) turndown: I need an HTML-to-Markdown converter and this one looked suitable enough. It has some bugs that I've worked around, so I might switch away from it later. +[Read the installation instructions →](https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md) diff --git a/registration.example.yaml b/registration.example.yaml deleted file mode 100644 index 9e7cd2c..0000000 --- a/registration.example.yaml +++ /dev/null @@ -1,23 +0,0 @@ -id: de8c56117637cb5d9f4ac216f612dc2adb1de4c09ae8d13553f28c33a28147c7 -hs_token: [a unique 64 character hex string] -as_token: [a unique 64 character hex string] -url: http://localhost:6693 -sender_localpart: _ooye_bot -protocols: - - discord -namespaces: - users: - - exclusive: true - regex: '@_ooye_.*' - aliases: - - exclusive: true - regex: '#_ooye_.*' -rate_limited: false -ooye: - namespace_prefix: _ooye_ - max_file_size: 5000000 - server_name: [the part after the colon in your matrix id, like cadence.moe] - server_origin: [the full protocol and domain of your actual matrix server's location, with no trailing slash, like https://matrix.cadence.moe] - invite: - # uncomment this to auto-invite the named user to newly created spaces and mark them as admin (PL 100) everywhere - # - @cadence:cadence.moe diff --git a/scripts/backfill.js b/scripts/backfill.js new file mode 100644 index 0000000..12d9da3 --- /dev/null +++ b/scripts/backfill.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// @ts-check + +console.log("-=- This script is experimental. It WILL mess up the room history on Matrix. -=-") +console.log() + +const {channel: channelID} = require("minimist")(process.argv.slice(2), {string: ["channel"]}) +if (!channelID) { + console.error("Usage: ./scripts/backfill.js --channel=") + process.exit(1) +} + +const assert = require("assert/strict") +const sqlite = require("better-sqlite3") +const backfill = new sqlite("scripts/backfill.db") +backfill.prepare("CREATE TABLE IF NOT EXISTS backfill (channel_id TEXT NOT NULL, message_id INTEGER NOT NULL, PRIMARY KEY (channel_id, message_id))").run() + +const HeatSync = require("heatsync") + +const {reg} = require("../src/matrix/read-registration") +const passthrough = require("../src/passthrough") + +const sync = new HeatSync({watchFS: false}) +const db = new sqlite("ooye.db") +Object.assign(passthrough, {sync, db}) + +const DiscordClient = require("../src/d2m/discord-client") + +const discord = new DiscordClient(reg.ooye.discord_token, "half") +passthrough.discord = discord + +const orm = sync.require("../src/db/orm") +passthrough.from = orm.from +passthrough.select = orm.select + +/** @type {import("../src/d2m/event-dispatcher")}*/ +const eventDispatcher = sync.require("../src/d2m/event-dispatcher") + +const roomID = passthrough.select("channel_room", "room_id", {channel_id: channelID}).pluck().get() +if (!roomID) { + console.error("Please choose a channel that's already bridged.") + process.exit(1) +} + +;(async () => { + await discord.cloud.connect() + console.log("Connected, waiting for data about requested channel...") + + discord.cloud.on("event", event) +})() + +const preparedInsert = backfill.prepare("INSERT INTO backfill (channel_id, message_id) VALUES (?, ?)") + +async function event(event) { + if (event.t !== "GUILD_CREATE") return + const channel = event.d.channels.find(c => c.id === channelID) + if (!channel) return + const guild_id = event.d.id + + let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0" + console.log(`OK, processing messages for #${channel.name}, continuing from ${last}`) + + while (last) { + const messages = await discord.snow.channel.getChannelMessages(channelID, {limit: 50, after: String(last)}) + messages.reverse() // More recent messages come first -> More recent messages come last + for (const message of messages) { + const simulatedGatewayDispatchData = { + guild_id, + backfill: true, + ...message + } + await eventDispatcher.onMessageCreate(discord, simulatedGatewayDispatchData) + preparedInsert.run(channelID, message.id) + } + last = messages.at(-1)?.id + } + + process.exit() +} diff --git a/scripts/capture-message-update-events.js b/scripts/capture-message-update-events.js old mode 100644 new mode 100755 index f345d3f..fb29a5d --- a/scripts/capture-message-update-events.js +++ b/scripts/capture-message-update-events.js @@ -1,3 +1,4 @@ +#!/usr/bin/env node // @ts-check // **** @@ -16,16 +17,16 @@ function fieldToPresenceValue(field) { const sqlite = require("better-sqlite3") const HeatSync = require("heatsync") -const config = require("../config") -const passthrough = require("../passthrough") +const {reg} = require("../src/matrix/read-registration") +const passthrough = require("../src/passthrough") const sync = new HeatSync({watchFS: false}) -Object.assign(passthrough, {config, sync}) +Object.assign(passthrough, {sync}) -const DiscordClient = require("../d2m/discord-client") +const DiscordClient = require("../src/d2m/discord-client") -const discord = new DiscordClient(config.discordToken, "no") +const discord = new DiscordClient(reg.ooye.discord_token, "no") passthrough.discord = discord ;(async () => { @@ -37,7 +38,7 @@ passthrough.discord = discord })() const events = new sqlite("scripts/events.db") -const sql = "INSERT INTO \"update\" (json, " + interestingFields.join(", ") + ") VALUES (" + "?".repeat(interestingFields.length + 1).split("").join(", ") + ")" +const sql = "INSERT INTO update_event (json, " + interestingFields.join(", ") + ") VALUES (" + "?".repeat(interestingFields.length + 1).split("").join(", ") + ")" console.log(sql) const prepared = events.prepare(sql) diff --git a/scripts/check-migrate.js b/scripts/check-migrate.js old mode 100644 new mode 100755 index 308ea87..04a4402 --- a/scripts/check-migrate.js +++ b/scripts/check-migrate.js @@ -1,3 +1,4 @@ +#!/usr/bin/env node // @ts-check // Trigger the database migration flow and exit after committing. @@ -5,11 +6,10 @@ const sqlite = require("better-sqlite3") -const config = require("../config") -const passthrough = require("../passthrough") -const db = new sqlite("db/ooye.db") -const migrate = require("../db/migrate") +const passthrough = require("../src/passthrough") +const db = new sqlite("ooye.db") +const migrate = require("../src/db/migrate") -Object.assign(passthrough, {config, db }) +Object.assign(passthrough, {db}) migrate.migrate(db) diff --git a/scripts/emoji-surrogates-statistics.js b/scripts/emoji-surrogates-statistics.js new file mode 100644 index 0000000..29abce3 --- /dev/null +++ b/scripts/emoji-surrogates-statistics.js @@ -0,0 +1,77 @@ +// @ts-check + +const fs = require("fs") +const {join} = require("path") +const s = fs.readFileSync(join(__dirname, "..", "src", "m2d", "converters", "emojis.txt"), "utf8").split("\n").map(x => encodeURIComponent(x)) +const searchPattern = "%EF%B8%8F" + +/** + * adapted from es.map.group-by.js in core-js + * @template K,V + * @param {V[]} items + * @param {(item: V) => K} fn + * @returns {Map} + */ +function groupBy(items, fn) { + var map = new Map(); + for (const value of items) { + var key = fn(value); + if (!map.has(key)) map.set(key, [value]); + else map.get(key).push(value); + } + return map; +} + +/** + * @param {number[]} items + * @param {number} width + */ +function xhistogram(items, width) { + const chars = " ▏▎▍▌▋▊▉" + const max = items.reduce((a, c) => c > a ? c : a, 0) + return items.map(v => { + const p = v / max * (width-1) + return ( + Array(Math.floor(p)).fill("█").join("") /* whole part */ + + chars[Math.ceil((p % 1) * (chars.length-1))] /* decimal part */ + ).padEnd(width) + }) +} + +/** + * @param {number[]} items + * @param {[number, number]} xrange + */ +function yhistogram(items, xrange, printHeader = false) { + const chars = "░▁_▂▃▄▅▆▇█" + const ones = "₀₁₂₃₄₅₆₇₈₉" + const tens = "0123456789" + const xy = [] + let max = 0 + /** value (x) -> frequency (y) */ + const grouped = groupBy(items, x => x) + for (let i = xrange[0]; i <= xrange[1]; i++) { + if (printHeader) { + if (i === -1) process.stdout.write("-") + else if (i.toString().at(-1) === "0") process.stdout.write(tens[i/10]) + else process.stdout.write(ones[i%10]) + } + const y = grouped.get(i)?.length ?? 0 + if (y > max) max = y + xy.push(y) + } + if (printHeader) console.log() + return xy.map(y => chars[Math.ceil(y / max * (chars.length-1))]).join("") +} + +const grouped = groupBy(s, x => x.length) +const sortedGroups = [...grouped.entries()].sort((a, b) => b[0] - a[0]) +let length = 0 +const lengthHistogram = xhistogram(sortedGroups.map(v => v[1].length), 10) +for (let i = 0; i < sortedGroups.length; i++) { + const [k, v] = sortedGroups[i] + const l = lengthHistogram[i] + const h = yhistogram(v.map(x => x.indexOf(searchPattern)), [-1, k - searchPattern.length], i === 0) + if (i === 0) length = h.length + 1 + console.log(`${h.padEnd(length, i % 2 === 0 ? "⸱" : " ")}length ${k.toString().padEnd(3)} ${l} ${v.length}`) +} diff --git a/scripts/migrate-from-old-bridge.js b/scripts/migrate-from-old-bridge.js old mode 100644 new mode 100755 index aec345d..36cf884 --- a/scripts/migrate-from-old-bridge.js +++ b/scripts/migrate-from-old-bridge.js @@ -1,24 +1,22 @@ +#!/usr/bin/env node // @ts-check const assert = require("assert").strict -/** @type {any} */ // @ts-ignore bad types from semaphore -const Semaphore = require("@chriscdn/promise-semaphore") +const {Semaphore} = require("@chriscdn/promise-semaphore") const sqlite = require("better-sqlite3") const HeatSync = require("heatsync") -const config = require("../config") -const passthrough = require("../passthrough") +const passthrough = require("../src/passthrough") const sync = new HeatSync({watchFS: false}) -/** @type {import("../matrix/read-registration")} */ -const reg = sync.require("../matrix/read-registration") +const {reg} = require("../src/matrix/read-registration") assert(reg.old_bridge) const oldAT = reg.old_bridge.as_token const newAT = reg.as_token const oldDB = new sqlite(reg.old_bridge.database) -const db = new sqlite("db/ooye.db") +const db = new sqlite("ooye.db") db.exec(`CREATE TABLE IF NOT EXISTS half_shot_migration ( discord_channel TEXT NOT NULL, @@ -26,19 +24,19 @@ db.exec(`CREATE TABLE IF NOT EXISTS half_shot_migration ( PRIMARY KEY("discord_channel") ) WITHOUT ROWID;`) -Object.assign(passthrough, {config, sync, db}) +Object.assign(passthrough, {sync, db}) -const DiscordClient = require("../d2m/discord-client") -const discord = new DiscordClient(config.discordToken, "half") +const DiscordClient = require("../src/d2m/discord-client") +const discord = new DiscordClient(reg.ooye.discord_token, "half") passthrough.discord = discord -/** @type {import("../d2m/actions/create-space")} */ +/** @type {import("../src/d2m/actions/create-space")} */ const createSpace = sync.require("../d2m/actions/create-space") -/** @type {import("../d2m/actions/create-room")} */ +/** @type {import("../src/d2m/actions/create-room")} */ const createRoom = sync.require("../d2m/actions/create-room") -/** @type {import("../matrix/mreq")} */ +/** @type {import("../src/matrix/mreq")} */ const mreq = sync.require("../matrix/mreq") -/** @type {import("../matrix/api")} */ +/** @type {import("../src/matrix/api")} */ const api = sync.require("../matrix/api") const sema = new Semaphore() @@ -116,7 +114,7 @@ async function migrateGuild(guild) { // (By the way, thread_parent is always null here because thread rooms would never be migrated because they are not in the old bridge.) db.transaction(() => { db.prepare("DELETE FROM channel_room WHERE channel_id = ?").run(channel.id) - db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, custom_avatar) VALUES (?, ?, ?, ?, ?)").run(channel.id, row.matrix_id, channel.name, preMigrationRow.nick, preMigrationRow.custom_avatar) + db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, custom_avatar, guild_id) VALUES (?, ?, ?, ?, ?, ?)").run(channel.id, row.matrix_id, channel.name, preMigrationRow.nick, preMigrationRow.custom_avatar, guild.id) console.log(`-- -- Added to database (transferred properties from previous OOYE room)`) })() } else { diff --git a/scripts/register.js b/scripts/register.js deleted file mode 100644 index e19adb3..0000000 --- a/scripts/register.js +++ /dev/null @@ -1,22 +0,0 @@ -// @ts-check - -const { AppServiceRegistration } = require("matrix-appservice"); - -let id = AppServiceRegistration.generateToken() -try { - const reg = require("../matrix/read-registration") - if (reg.id) id = reg.id -} catch (e) {} - -// creating registration files -const newReg = new AppServiceRegistration(null); -newReg.setAppServiceUrl("http://localhost:6693"); -newReg.setId(id); -newReg.setHomeserverToken(AppServiceRegistration.generateToken()); -newReg.setAppServiceToken(AppServiceRegistration.generateToken()); -newReg.setSenderLocalpart("_ooye_bot"); -newReg.addRegexPattern("users", "@_ooye_.*", true); -newReg.addRegexPattern("aliases", "#_ooye_.*", true); -newReg.setProtocols(["discord"]); // For 3PID lookups -newReg.setRateLimited(false); -newReg.outputAsYaml("registration.yaml"); diff --git a/scripts/remove-old-bridged-users.js b/scripts/remove-old-bridged-users.js new file mode 100644 index 0000000..d8910bd --- /dev/null +++ b/scripts/remove-old-bridged-users.js @@ -0,0 +1,39 @@ +// @ts-check + +const HeatSync = require("heatsync") +const sync = new HeatSync({watchFS: false}) + +const sqlite = require("better-sqlite3") +const db = new sqlite("db/ooye.db") + +const passthrough = require("../src/passthrough") +Object.assign(passthrough, {db, sync}) + +const api = require("../src/matrix/api") +const mreq = require("../src/matrix/mreq") + +const rooms = db.prepare("select room_id from channel_room").pluck().all() + +;(async () => { + // Step 5: Kick users starting with @_discord_ + await mreq.withAccessToken("baby", async () => { + for (const roomID of rooms) { + try { + const members = await api.getJoinedMembers(roomID) + for (const mxid of Object.keys(members.joined)) { + if (mxid.startsWith("@_discord_") && !mxid.startsWith("@_discord_bot")) { + await api.leaveRoom(roomID, mxid) + } + } + await api.setUserPower(roomID, "@_discord_bot:cadence.moe", 0) + await api.leaveRoom(roomID) + } catch (e) { + if (e.message.includes("Appservice not in room")) { + // ok + } else { + throw e + } + } + } + }) +})() diff --git a/scripts/save-channel-names-to-db.js b/scripts/save-channel-names-to-db.js old mode 100644 new mode 100755 index 1e5c541..1f36a73 --- a/scripts/save-channel-names-to-db.js +++ b/scripts/save-channel-names-to-db.js @@ -1,19 +1,20 @@ +#!/usr/bin/env node // @ts-check const sqlite = require("better-sqlite3") const HeatSync = require("heatsync") -const config = require("../config") -const passthrough = require("../passthrough") -const db = new sqlite("db/ooye.db") +const {reg} = require("../src/matrix/read-registration") +const passthrough = require("../src/passthrough") +const db = new sqlite("ooye.db") const sync = new HeatSync({watchFS: false}) -Object.assign(passthrough, {config, sync, db}) +Object.assign(passthrough, {sync, db}) -const DiscordClient = require("../d2m/discord-client") +const DiscordClient = require("../src/d2m/discord-client") -const discord = new DiscordClient(config.discordToken, "no") +const discord = new DiscordClient(reg.ooye.discord_token, "no") passthrough.discord = discord ;(async () => { diff --git a/scripts/save-event-types-to-db.js b/scripts/save-event-types-to-db.js old mode 100644 new mode 100755 index 290aa4a..edfbb9c --- a/scripts/save-event-types-to-db.js +++ b/scripts/save-event-types-to-db.js @@ -1,16 +1,17 @@ +#!/usr/bin/env node // @ts-check const sqlite = require("better-sqlite3") const HeatSync = require("heatsync") -const passthrough = require("../passthrough") -const db = new sqlite("db/ooye.db") +const passthrough = require("../src/passthrough") +const db = new sqlite("ooye.db") const sync = new HeatSync({watchFS: false}) Object.assign(passthrough, {sync, db}) -const api = require("../matrix/api") +const api = require("../src/matrix/api") /** @type {{event_id: string, room_id: string, event_type: string}[]} */ // @ts-ignore const rows = db.prepare("SELECT event_id, room_id, event_type FROM event_message INNER JOIN message_channel USING (message_id) INNER JOIN channel_room USING (channel_id)").all() diff --git a/scripts/seed.js b/scripts/seed.js deleted file mode 100644 index 0f2f23d..0000000 --- a/scripts/seed.js +++ /dev/null @@ -1,125 +0,0 @@ -// @ts-check - -console.log("This could take up to 30 seconds. Please be patient.") - -const assert = require("assert").strict -const fs = require("fs") -const sqlite = require("better-sqlite3") -const HeatSync = require("heatsync") - -const args = require("minimist")(process.argv.slice(2), {string: ["emoji-guild"]}) - -const config = require("../config") -const passthrough = require("../passthrough") -const db = new sqlite("db/ooye.db") -const migrate = require("../db/migrate") - -const sync = new HeatSync({watchFS: false}) - -Object.assign(passthrough, { sync, config, db }) - -const orm = sync.require("../db/orm") -passthrough.from = orm.from -passthrough.select = orm.select - -const DiscordClient = require("../d2m/discord-client") -const discord = new DiscordClient(config.discordToken, "no") -passthrough.discord = discord - -const api = require("../matrix/api") -const file = require("../matrix/file") -const reg = require("../matrix/read-registration") -const utils = require("../m2d/converters/utils") - -function die(message) { - console.error(message) - process.exit(1) -} - -async function uploadAutoEmoji(guild, name, filename) { - let emoji = guild.emojis.find(e => e.name === name) - if (!emoji) { - console.log(` Uploading ${name}...`) - const data = fs.readFileSync(filename, null) - emoji = await discord.snow.guildAssets.createEmoji(guild.id, {name, image: "data:image/png;base64," + data.toString("base64")}) - } else { - console.log(` Reusing ${name}...`) - } - db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES (?, ?, ?)").run(emoji.name, emoji.id, guild.id) - return emoji -} - -;(async () => { - const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}` - - // ensure registration is correctly set... - assert(reg.sender_localpart.startsWith(reg.ooye.namespace_prefix)) // appservice's localpart must be in the namespace it controls - assert(utils.eventSenderIsFromDiscord(mxid)) // appservice's mxid must be in the namespace it controls - assert(reg.ooye.server_origin.match(/^https?:\/\//)) // must start with http or https - assert.notEqual(reg.ooye.server_origin.slice(-1), "/") // must not end in slash - console.log("✅ Configuration looks good...") - - // database ddl... - await migrate.migrate(db) - - // add initial rows to database, like adding the bot to sim... - db.prepare("INSERT OR IGNORE INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run("0", reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), reg.sender_localpart, mxid) - - console.log("✅ Database is ready...") - - // upload the L1 L2 emojis to some guild - const emojis = db.prepare("SELECT name FROM auto_emoji WHERE name = 'L1' OR name = 'L2'").pluck().all() - if (emojis.length !== 2) { - // If an argument was supplied, always use that one - let guild = null - if (args["emoji-guild"]) { - if (typeof args["emoji-guild"] === "string") { - guild = await discord.snow.guild.getGuild(args["emoji-guild"]) - } - if (!guild) return die(`Error: You asked emojis to be uploaded to guild ID ${args["emoji-guild"]}, but the bot isn't in that guild.`) - } - // Otherwise, check if we have already registered an auto emoji guild - if (!guild) { - const guildID = passthrough.select("auto_emoji", "guild_id", {name: "_"}).pluck().get() - if (guildID) { - guild = await discord.snow.guild.getGuild(guildID, false) - } - } - // Otherwise, check if we should create a new guild - if (!guild) { - const guilds = await discord.snow.user.getGuilds({limit: 11, with_counts: false}) - if (guilds.length < 10) { - console.log(" Creating a guild for emojis...") - guild = await discord.snow.guild.createGuild({name: "OOYE Emojis"}) - } - } - // Otherwise, it's the user's problem - if (!guild) { - return die(`Error: The bot needs to upload some emojis. Please say where to upload them to. Run seed.js again with --emoji-guild=GUILD_ID`) - } - // Upload those emojis to the chosen location - db.prepare("REPLACE INTO auto_emoji (name, emoji_id, guild_id) VALUES ('_', '_', ?)").run(guild.id) - await uploadAutoEmoji(guild, "L1", "docs/img/L1.png") - await uploadAutoEmoji(guild, "L2", "docs/img/L2.png") - } - console.log("✅ Emojis are ready...") - - // ensure homeserver well-known is valid and returns reg.ooye.server_name... - - // upload initial images... - const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png") - - // set profile data on discord... - const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer()) - await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")}) - await discord.snow.requestHandler.request(`/applications/@me`, {}, "patch", "json", {description: "Powered by **Out Of Your Element**\nhttps://gitdab.com/cadence/out-of-your-element"}) - console.log("✅ Discord profile updated...") - - // set profile data on homeserver... - await api.profileSetDisplayname(mxid, "Out Of Your Element") - await api.profileSetAvatarUrl(mxid, avatarUrl) - console.log("✅ Matrix profile updated...") - - console.log("Good to go. I hope you enjoy Out Of Your Element.") - process.exit() -})() diff --git a/scripts/setup.js b/scripts/setup.js new file mode 100644 index 0000000..6bff293 --- /dev/null +++ b/scripts/setup.js @@ -0,0 +1,368 @@ +#!/usr/bin/env node +// @ts-check + +const assert = require("assert").strict +const fs = require("fs") +const sqlite = require("better-sqlite3") +const {scheduler} = require("timers/promises") +const {isDeepStrictEqual} = require("util") +const {createServer} = require("http") +const {join} = require("path") + +const {prompt} = require("enquirer") +const Input = require("enquirer/lib/prompts/input") +const {magenta, bold, cyan} = require("ansi-colors") +const HeatSync = require("heatsync") +const {SnowTransfer} = require("snowtransfer") +const DiscordTypes = require("discord-api-types/v10") +const {createApp, defineEventHandler, toNodeListener} = require("h3") + +// Move database file if it's still in the old location +if (fs.existsSync("db")) { + if (fs.existsSync("db/ooye.db")) { + fs.renameSync("db/ooye.db", "ooye.db") + } + const files = fs.readdirSync("db") + if (files.length) { + console.error("The db folder is deprecated and must be removed. Your ooye.db database file has already been moved to the root of the repo. You must manually move or delete the remaining files:") + for (const file of files) { + console.error(file) + } + process.exit(1) + } + fs.rmSync("db", {recursive: true}) +} + +const passthrough = require("../src/passthrough") +const db = new sqlite("ooye.db") +const migrate = require("../src/db/migrate") + +const sync = new HeatSync({watchFS: false}) + +Object.assign(passthrough, {sync, db}) + +const orm = sync.require("../src/db/orm") +passthrough.from = orm.from +passthrough.select = orm.select + +let registration = require("../src/matrix/read-registration") +let {reg, getTemplateRegistration, writeRegistration, readRegistration, checkRegistration, registrationFilePath} = registration + +const {setupEmojis} = require("../src/m2d/actions/setup-emojis") + +function die(message) { + console.error(message) + process.exit(1) +} + +async function suggestWellKnown(serverUrlPrompt, url, otherwise) { + try { + var json = await fetch(`${url}/.well-known/matrix/client`).then(res => res.json()) + let baseURL = json["m.homeserver"].base_url.replace(/\/$/, "") + if (baseURL && baseURL !== url) { + serverUrlPrompt.initial = baseURL + return `Did you mean: ${bold(baseURL)}? (Enter to accept)` + } + } catch (e) {} + return otherwise +} + +async function validateHomeserverOrigin(serverUrlPrompt, url) { + if (!url.match(/^https?:\/\//)) return "Must be a URL" + if (url.match(/\/$/)) return "Must not end with a slash" + process.stdout.write(magenta(" checking, please wait...")) + try { + var res = await fetch(`${url}/_matrix/client/versions`) + if (res.status !== 200) { + return suggestWellKnown(serverUrlPrompt, url, `There is no Matrix server at that URL (${url}/_matrix/client/versions returned ${res.status})`) + } + } catch (e) { + return e.message + } + try { + var json = await res.json() + if (!Array.isArray(json?.versions) || !json.versions.includes("v1.11")) { + return `OOYE needs Matrix version v1.11, but ${url} doesn't support this` + } + } catch (e) { + return suggestWellKnown(serverUrlPrompt, url, `There is no Matrix server at that URL (${url}/_matrix/client/versions is not JSON)`) + } + return true +} + +function defineEchoHandler() { + return defineEventHandler(event => { + return "Out Of Your Element is listening.\n" + + `Received a ${event.method} request on path ${event.path}\n` + }) +} + +;(async () => { + // create registration file with prompts... + if (!reg) { + console.log("What is the name of your homeserver? This is the part after : in your username.") + /** @type {{server_name: string}} */ + const serverNameResponse = await prompt({ + type: "input", + name: "server_name", + message: "Homeserver name", + validate: serverName => !!serverName.match(/[a-z][a-z.]+[a-z]/) + }) + + console.log("What is the URL of your homeserver?") + const serverOriginPrompt = new Input({ + type: "input", + name: "server_origin", + message: "Homeserver URL", + initial: () => `https://${serverNameResponse.server_name}`, + validate: url => validateHomeserverOrigin(serverOriginPrompt, url) + }) + /** @type {string} */ // @ts-ignore + const serverOrigin = await serverOriginPrompt.run() + + const app = createApp() + app.use(defineEchoHandler()) + const server = createServer(toNodeListener(app)) + await server.listen(6693) + + console.log("OOYE has its own web server. It needs to be accessible on the public internet.") + console.log("You need to enter a public URL where you will be able to host this web server.") + console.log("OOYE listens on localhost:6693, so you will probably have to set up a reverse proxy.") + console.log("Examples: https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md#appendix") + console.log("Now listening on port 6693. Feel free to send some test requests.") + /** @type {{bridge_origin: string}} */ + const bridgeOriginResponse = await prompt({ + type: "input", + name: "bridge_origin", + message: "URL to reach OOYE", + initial: () => `https://bridge.${serverNameResponse.server_name}`, + validate: async url => { + process.stdout.write(magenta(" checking, please wait...")) + try { + const res = await fetch(url) + if (res.status !== 200) return `Server returned status code ${res.status}` + const text = await res.text() + if (!text.startsWith("Out Of Your Element is listening.")) return `Server does not point to OOYE` + return true + } catch (e) { + return e.message + } + } + }) + bridgeOriginResponse.bridge_origin = bridgeOriginResponse.bridge_origin.replace(/\/+$/, "") // remove trailing slash + + await server.close() + + console.log("What is your Discord bot token?") + console.log("Go to https://discord.com/developers, create or pick an app, go to the Bot section, and reset the token.") + /** @type {SnowTransfer} */ // @ts-ignore + let snow = null + /** @type {{id: string, flags: number, redirect_uris: string[], description: string}} */ // @ts-ignore + let client = null + /** @type {{discord_token: string}} */ + const discordTokenResponse = await prompt({ + type: "input", + name: "discord_token", + message: "Bot token", + validate: async token => { + process.stdout.write(magenta(" checking, please wait...")) + try { + snow = new SnowTransfer(token) + client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json") + return true + } catch (e) { + return e.message + } + } + }) + + const mandatoryIntentFlags = DiscordTypes.ApplicationFlags.GatewayMessageContent | DiscordTypes.ApplicationFlags.GatewayMessageContentLimited + if (!(client.flags & mandatoryIntentFlags)) { + console.log(`On that same page, scroll down to Privileged Gateway Intents and enable all switches.`) + await prompt({ + type: "invisible", + name: "intents", + message: "Press Enter when you've enabled them", + validate: async token => { + process.stdout.write(magenta("checking, please wait...")) + client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json") + if (client.flags & mandatoryIntentFlags) { + return true + } else { + return "Switches have not been enabled yet" + } + } + }) + } + + console.log("Would you like to require a password to add your bot to servers? This will discourage others from using your bridge.") + console.log("Important: To make it truly private, you MUST ALSO disable Public Bot in the Discord bot configuration page.") + /** @type {{web_password: string}} */ + const passwordResponse = await prompt({ + type: "text", + name: "web_password", + message: "Choose a simple password (optional)" + }) + + console.log("To fulfill license obligations, I recommend mentioning Out Of Your Element in your Discord bot's profile.") + console.log("On the Discord bot configuration page, go to General and add something like this to the description:") + console.log(cyan("Powered by **Out Of Your Element**")) + console.log(cyan("https://gitdab.com/cadence/out-of-your-element")) + await prompt({ + type: "invisible", + name: "description", + message: "Press Enter to acknowledge", + validate: async token => { + process.stdout.write(magenta("checking, please wait...")) + client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json") + if (client.description?.match(/out.of.your.element/i)) { + return true + } else { + return "Description must name or link Out Of Your Element" + } + } + }) + + console.log("What is your Discord client secret?") + console.log(`You can find it in the application's OAuth2 section: https://discord.com/developers/applications/${client.id}/oauth2`) + /** @type {{discord_client_secret: string}} */ + const clientSecretResponse = await prompt({ + type: "input", + name: "discord_client_secret", + message: "Client secret" + }) + + const expectedUri = `${bridgeOriginResponse.bridge_origin}/oauth` + if (!client.redirect_uris.includes(expectedUri)) { + console.log(`On that same page, scroll down to Redirects and add this URI: ${cyan(expectedUri)}`) + await prompt({ + type: "invisible", + name: "redirect_uri", + message: "Press Enter when you've added it", + validate: async token => { + process.stdout.write(magenta("checking, please wait...")) + client = await snow.requestHandler.request(`/applications/@me`, {}, "get", "json") + if (client.redirect_uris.includes(expectedUri)) { + return true + } else { + return "Redirect URI has not been added yet" + } + } + }) + } + + const template = getTemplateRegistration(serverNameResponse.server_name) + reg = { + ...template, + url: bridgeOriginResponse.bridge_origin, + ooye: { + ...template.ooye, + ...bridgeOriginResponse, + server_origin: serverOrigin, + ...discordTokenResponse, + ...clientSecretResponse, + ...passwordResponse + } + } + registration.reg = reg + checkRegistration(reg) + writeRegistration(reg) + console.log(`✅ Your responses have been saved as ${registrationFilePath}`) + } else { + try { + checkRegistration(reg) + console.log(`✅ Skipped questions - reusing data from ${registrationFilePath}`) + } catch (e) { + console.log(`❌ Failed to reuse data from ${registrationFilePath}`) + console.log("Consider deleting this file. You can re-run setup to safely make a new one.") + console.log("") + console.log(e.toString().replace(/^ *\n/gm, "")) + process.exit(1) + } + } + console.log(` In ${cyan("Synapse")}, you need to reference that file in your homeserver.yaml and ${cyan("restart Synapse")}.`) + console.log(" https://element-hq.github.io/synapse/latest/application_services.html") + console.log(` In ${cyan("Conduit")}, you need to send the file contents to the #admins room.`) + console.log(" https://docs.conduit.rs/appservices.html") + console.log() + + // Done with user prompts, reg is now guaranteed to be valid + const api = require("../src/matrix/api") + const file = require("../src/matrix/file") + const DiscordClient = require("../src/d2m/discord-client") + const discord = new DiscordClient(reg.ooye.discord_token, "no") + passthrough.discord = discord + + const {as} = require("../src/matrix/appservice") + as.router.use("/**", defineEchoHandler()) + + console.log("⏳ Waiting for you to register the file with your homeserver... (Ctrl+C to cancel)") + process.once("SIGINT", () => { + console.log("(Ctrl+C) Quit early. Please re-run setup later and allow it to complete.") + process.exit(1) + }) + + let itWorks = false + let lastError = null + do { + const result = await api.ping().catch(e => ({ok: false, status: "net", root: e.message})) + // If it didn't work, log details and retry after some time + itWorks = result.ok + if (!itWorks) { + // Log the full error data if the error is different to last time + if (!isDeepStrictEqual(lastError, result.root)) { + if (typeof result.root === "string") { + console.log(`\nCannot reach homeserver: ${result.root}`) + } else if (result.root.error) { + console.log(`\nHomeserver said: [${result.status}] ${result.root.error}`) + } else { + console.log(`\nHomeserver said: [${result.status}] ${JSON.stringify(result.root)}`) + } + lastError = result.root + } else { + process.stderr.write(".") + } + await scheduler.wait(5000) + } + } while (!itWorks) + console.log("") + + as.close().catch(() => {}) + + const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}` + + // database ddl... + await migrate.migrate(db) + + // add initial rows to database, like adding the bot to sim... + const client = await discord.snow.user.getSelf() + db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING").run(client.id, client.username, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), mxid) + + console.log("✅ Database is ready...") + + // ensure appservice bot user is registered... + await api.register(reg.sender_localpart) + + // upload initial images... + const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png") + + console.log("✅ Matrix appservice login works...") + + // upload the L1 L2 emojis to user emojis + await setupEmojis() + console.log("✅ Emojis are ready...") + + // set profile data on discord... + const avatarImageBuffer = await fetch("https://cadence.moe/friends/out_of_your_element.png").then(res => res.arrayBuffer()) + await discord.snow.user.updateSelf({avatar: "data:image/png;base64," + Buffer.from(avatarImageBuffer).toString("base64")}) + console.log("✅ Discord profile updated...") + + // set profile data on homeserver... + console.log("⏩ Updating Matrix profile... (If you've joined lots of rooms, this is slow. Please allow at least 30 seconds.)") + await api.profileSetDisplayname(mxid, "Out Of Your Element") + await api.profileSetAvatarUrl(mxid, avatarUrl) + console.log("✅ Matrix profile updated...") + + console.log("Good to go. I hope you enjoy Out Of Your Element.") + process.exit() +})() diff --git a/scripts/start-server.js b/scripts/start-server.js new file mode 100755 index 0000000..0d4753a --- /dev/null +++ b/scripts/start-server.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// @ts-check + +const {createServer} = require("http") +const EventEmitter = require("events") +const {createApp, createRouter, toNodeListener} = require("h3") +const sqlite = require("better-sqlite3") +const migrate = require("../src/db/migrate") +const HeatSync = require("heatsync") + +const {reg} = require("../src/matrix/read-registration") +const passthrough = require("../src/passthrough") +const db = new sqlite("ooye.db") + +const sync = new HeatSync() + +Object.assign(passthrough, {sync, db}) + +const DiscordClient = require("../src/d2m/discord-client") + +const discord = new DiscordClient(reg.ooye.discord_token, "half") +passthrough.discord = discord + +const {as} = require("../src/matrix/appservice") +passthrough.as = as + +const orm = sync.require("../src/db/orm") +passthrough.from = orm.from +passthrough.select = orm.select + +;(async () => { + await migrate.migrate(db) + await discord.cloud.connect() + console.log("Discord gateway started") + sync.require("../src/web/server") + + require("../src/stdin") +})() diff --git a/scripts/wal.js b/scripts/wal.js old mode 100644 new mode 100755 index 1ad15d0..625f2ba --- a/scripts/wal.js +++ b/scripts/wal.js @@ -1,6 +1,7 @@ +#!/usr/bin/env node // @ts-check const sqlite = require("better-sqlite3") -const db = new sqlite("db/ooye.db", {fileMustExist: true}) +const db = new sqlite("ooye.db", {fileMustExist: true}) db.pragma("journal_mode = wal") db.close() diff --git a/d2m/actions/add-reaction.js b/src/d2m/actions/add-reaction.js similarity index 95% rename from d2m/actions/add-reaction.js rename to src/d2m/actions/add-reaction.js index b131f13..8d86e5f 100644 --- a/d2m/actions/add-reaction.js +++ b/src/d2m/actions/add-reaction.js @@ -25,7 +25,7 @@ async function addReaction(data) { if (!parentID) return // Nothing can be done if the parent message was never bridged. assert.equal(typeof parentID, "string") - const key = await emojiToKey.emojiToKey(data.emoji) + const key = await emojiToKey.emojiToKey(data.emoji, data.message_id) const shortcode = key.startsWith("mxc://") ? `:${data.emoji.name}:` : undefined const roomID = await createRoom.ensureRoom(data.channel_id) diff --git a/d2m/actions/announce-thread.js b/src/d2m/actions/announce-thread.js similarity index 67% rename from d2m/actions/announce-thread.js rename to src/d2m/actions/announce-thread.js index 86c6412..324c7a5 100644 --- a/d2m/actions/announce-thread.js +++ b/src/d2m/actions/announce-thread.js @@ -8,6 +8,8 @@ const {discord, sync, db, select} = passthrough const threadToAnnouncement = sync.require("../converters/thread-to-announcement") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") +/** @type {import("./register-user")} */ +const registerUser = sync.require("./register-user") /** * @param {string} parentRoomID @@ -15,10 +17,10 @@ const api = sync.require("../../matrix/api") * @param {import("discord-api-types/v10").APIThreadChannel} thread */ async function announceThread(parentRoomID, threadRoomID, thread) { - const creatorMxid = select("sim", "mxid", {user_id: thread.owner_id}).pluck().get() - - const content = await threadToAnnouncement.threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, {api}) - + assert(thread.owner_id) + // @ts-ignore + const creatorMxid = await registerUser.ensureSimJoined({id: thread.owner_id}, parentRoomID) + const content = await threadToAnnouncement.threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, {api}) await api.sendEvent(parentRoomID, "m.room.message", content, creatorMxid) } diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js new file mode 100644 index 0000000..ff5782d --- /dev/null +++ b/src/d2m/actions/create-room.js @@ -0,0 +1,574 @@ +// @ts-check + +const assert = require("assert").strict +const DiscordTypes = require("discord-api-types/v10") +const Ty = require("../../types") +const {reg} = require("../../matrix/read-registration") + +const passthrough = require("../../passthrough") +const {discord, sync, db, select, from} = passthrough +/** @type {import("../../matrix/file")} */ +const file = sync.require("../../matrix/file") +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("../../matrix/mreq")} */ +const mreq = sync.require("../../matrix/mreq") +/** @type {import("../../matrix/kstate")} */ +const ks = sync.require("../../matrix/kstate") +/** @type {import("../../discord/utils")} */ +const dUtils = sync.require("../../discord/utils") +/** @type {import("../../m2d/converters/utils")} */ +const mUtils = sync.require("../../m2d/converters/utils") +/** @type {import("./create-space")} */ +const createSpace = sync.require("./create-space") + +/** + * There are 3 levels of room privacy: + * 0: Room is invite-only. + * 1: Anybody can use a link to join. + * 2: Room is published in room directory. + */ +const PRIVACY_ENUMS = { + PRESET: ["private_chat", "public_chat", "public_chat"], + VISIBILITY: ["private", "private", "private"], + SPACE_HISTORY_VISIBILITY: ["invited", "world_readable", "world_readable"], // copying from element client + ROOM_HISTORY_VISIBILITY: ["shared", "shared", "world_readable"], // any events sent after are visible, but for world_readable anybody can read without even joining + GUEST_ACCESS: ["can_join", "forbidden", "forbidden"], // whether guests can join space if other conditions are met + SPACE_JOIN_RULES: ["invite", "public", "public"], + ROOM_JOIN_RULES: ["restricted", "public", "public"] +} + +const DEFAULT_PRIVACY_LEVEL = 0 + +const READ_ONLY_ROOM_EVENTS_DEFAULT_POWER = 50 + +/** @type {Map>} channel ID -> Promise */ +const inflightRoomCreate = new Map() + +/** + * @param {{id: string, name: string, topic?: string?, type: number, parent_id?: string?}} channel + * @param {{id: string}} guild + * @param {string | null | undefined} customName + */ +function convertNameAndTopic(channel, guild, customName) { + // @ts-ignore + const parentChannel = discord.channels.get(channel.parent_id) + let channelPrefix = + ( parentChannel?.type === DiscordTypes.ChannelType.GuildForum ? "" + : channel.type === DiscordTypes.ChannelType.PublicThread ? "[⛓️] " + : channel.type === DiscordTypes.ChannelType.AnnouncementThread ? "[⛓️] " + : channel.type === DiscordTypes.ChannelType.PrivateThread ? "[🔒⛓️] " + : channel.type === DiscordTypes.ChannelType.GuildVoice ? "[🔊] " + : "") + const chosenName = customName || (channelPrefix + channel.name); + const maybeTopicWithPipe = channel.topic ? ` | ${channel.topic}` : ''; + const maybeTopicWithNewlines = channel.topic ? `${channel.topic}\n\n` : ''; + const channelIDPart = `Channel ID: ${channel.id}`; + const guildIDPart = `Guild ID: ${guild.id}`; + + const convertedTopic = customName + ? `#${channel.name}${maybeTopicWithPipe}\n\n${channelIDPart}\n${guildIDPart}` + : `${maybeTopicWithNewlines}${channelIDPart}\n${guildIDPart}`; + + return [chosenName, convertedTopic]; +} + +/** + * Async because it may create the guild and/or upload the guild icon to mxc. + * @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel + * @param {DiscordTypes.APIGuild} guild + * @param {{api: {getStateEvent: typeof api.getStateEvent}}} di simple-as-nails dependency injection for the matrix API + */ +async function channelToKState(channel, guild, di) { + // @ts-ignore + const parentChannel = discord.channels.get(channel.parent_id) + + /** Used for membership/permission checks. */ + const guildSpaceID = await createSpace.ensureSpace(guild) + /** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */ + let parentSpaceID = guildSpaceID + if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) { + parentSpaceID = await ensureRoom(channel.parent_id) + assert(typeof parentSpaceID === "string") + } + + const channelRow = select("channel_room", ["nick", "custom_avatar", "custom_topic"], {channel_id: channel.id}).get() + const customName = channelRow?.nick + const customAvatar = channelRow?.custom_avatar + const hasCustomTopic = channelRow?.custom_topic + const [convertedName, convertedTopic] = convertNameAndTopic(channel, guild, customName) + + const avatarEventContent = {} + if (customAvatar) { + avatarEventContent.url = customAvatar + } else if (guild.icon) { + avatarEventContent.url = {$url: file.guildIcon(guild)} + } + + const privacyLevel = select("guild_space", "privacy_level", {guild_id: guild.id}).pluck().get() + assert(privacyLevel != null) // already ensured the space exists + let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel] + if (channel["thread_metadata"]) history_visibility = "world_readable" + + /** @type {{join_rule: string, allow?: any}} */ + let join_rules = { + join_rule: "restricted", + allow: [{ + type: "m.room_membership", + room_id: guildSpaceID + }] + } + if (PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel] !== "restricted") { + join_rules = {join_rule: PRIVACY_ENUMS.ROOM_JOIN_RULES[privacyLevel]} + } + + const everyonePermissions = dUtils.getPermissions([], guild.roles, undefined, channel.permission_overwrites) + const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages) + const everyoneCanMentionEveryone = dUtils.hasAllPermissions(everyonePermissions, ["MentionEveryone"]) + + const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all() + const globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {}) + + /** @type {Ty.Event.M_Power_Levels} */ + const spacePowerEvent = await di.api.getStateEvent(guildSpaceID, "m.room.power_levels", "") + const spacePower = spacePowerEvent.users + + /** @type {any} */ + const channelKState = { + "m.room.name/": {name: convertedName}, + "m.room.topic/": {topic: convertedTopic}, + "m.room.avatar/": avatarEventContent, + "m.room.guest_access/": {guest_access: PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]}, + "m.room.history_visibility/": {history_visibility}, + [`m.space.parent/${parentSpaceID}`]: { + via: [reg.ooye.server_name], + canonical: true + }, + /** @type {{join_rule: string, [x: string]: any}} */ + "m.room.join_rules/": join_rules, + /** @type {Ty.Event.M_Power_Levels} */ + "m.room.power_levels/": { + events_default: everyoneCanSend ? 0 : READ_ONLY_ROOM_EVENTS_DEFAULT_POWER, + events: { + "m.reaction": 0, + "m.room.redaction": 0 // only affects redactions of own events, required to be able to un-react + }, + notifications: { + room: everyoneCanMentionEveryone ? 0 : 20 + }, + users: {...spacePower, ...globalAdminPower} + }, + "chat.schildi.hide_ui/read_receipts": { + }, + [`uk.half-shot.bridge/moe.cadence.ooye://discord/${guild.id}/${channel.id}`]: { + bridgebot: `@${reg.sender_localpart}:${reg.ooye.server_name}`, + protocol: { + id: "discord", + displayname: "Discord" + }, + network: { + id: guild.id, + displayname: guild.name, + avatar_url: {$url: file.guildIcon(guild)} + }, + channel: { + id: channel.id, + displayname: channel.name, + external_url: `https://discord.com/channels/${guild.id}/${channel.id}` + } + } + } + + // Don't overwrite room topic if the topic has been customised + if (hasCustomTopic) delete channelKState["m.room.topic/"] + + // Don't add a space parent if it's self service + // (The person setting up self-service has already put it in their preferred space to be able to get this far.) + const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get() + if (autocreate === 0 && ![DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { + delete channelKState[`m.space.parent/${parentSpaceID}`] + } + + return {spaceID: parentSpaceID, privacyLevel, channelKState} +} + +/** + * Create a bridge room, store the relationship in the database, and add it to the guild's space. + * @param {DiscordTypes.APIGuildTextChannel} channel + * @param guild + * @param {string} spaceID + * @param {any} kstate + * @param {number} privacyLevel + * @returns {Promise} room ID + */ +async function createRoom(channel, guild, spaceID, kstate, privacyLevel) { + let threadParent = null + if (channel.type === DiscordTypes.ChannelType.PublicThread) threadParent = channel.parent_id + + let spaceCreationContent = {} + if (channel.type === DiscordTypes.ChannelType.GuildForum) spaceCreationContent = {creation_content: {type: "m.space"}} + + // Name and topic can be done earlier in room creation rather than in initial_state + // https://spec.matrix.org/latest/client-server-api/#creation + const name = kstate["m.room.name/"].name + delete kstate["m.room.name/"] + assert(name) + const topic = kstate["m.room.topic/"].topic + delete kstate["m.room.topic/"] + assert(topic) + + const roomID = await postApplyPowerLevels(kstate, async kstate => { + const roomID = await api.createRoom({ + name, + topic, + preset: PRIVACY_ENUMS.PRESET[privacyLevel], // This is closest to what we want, but properties from kstate override it anyway + visibility: PRIVACY_ENUMS.VISIBILITY[privacyLevel], + invite: [], + initial_state: await ks.kstateToState(kstate), + ...spaceCreationContent + }) + + db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES (?, ?, ?, NULL, ?)").run(channel.id, roomID, channel.name, threadParent) + + return roomID + }) + + // Put the newly created child into the space + await _syncSpaceMember(channel, spaceID, roomID, guild.id) + + return roomID +} + +/** + * Handling power levels separately. The spec doesn't specify what happens, Dendrite differs, + * and Synapse does an absolutely insane *shallow merge* of what I provide on top of what it creates. + * We don't want the `events` key to be overridden completely. + * https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210 + * https://github.com/matrix-org/matrix-spec/issues/492 + * @param {any} kstate + * @param {(_: any) => Promise} callback must return room ID + * @returns {Promise} room ID + */ +async function postApplyPowerLevels(kstate, callback) { + const powerLevelContent = kstate["m.room.power_levels/"] + const kstateWithoutPowerLevels = {...kstate} + delete kstateWithoutPowerLevels["m.room.power_levels/"] + delete kstateWithoutPowerLevels["chat.schildi.hide_ui/read_receipts"] + + /** @type {string} */ + const roomID = await callback(kstateWithoutPowerLevels) + + // Now *really* apply the power level overrides on top of what Synapse *really* set + if (powerLevelContent) { + const newRoomKState = await ks.roomToKState(roomID) + const newRoomPowerLevelsDiff = ks.diffKState(newRoomKState, {"m.room.power_levels/": powerLevelContent}) + await ks.applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff) + } + + return roomID +} + +/** + * @param {DiscordTypes.APIGuildChannel} channel + */ +function channelToGuild(channel) { + const guildID = channel.guild_id + assert(guildID) + const guild = discord.guilds.get(guildID) + assert(guild) + return guild +} + +/** + * This function handles whether it's allowed to bridge messages in this channel, and if so, where to. + * This has to account for whether self-service is enabled for the guild or not. + * This also has to account for different channel types, like forum channels (which need the + * parent forum to already exist, and ignore the self-service setting), or thread channels (which + * need the parent channel to already exist, and ignore the self-service setting). + * @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread + * @param {string} guildID + * @returns obj if bridged; 1 if autocreatable; null/undefined if guild is not bridged; 0 if self-service and not autocreatable thread + */ +function existsOrAutocreatable(channel, guildID) { + // 1. If the channel is already linked somewhere, it's always okay to bridge to that destination, no matter what. Yippee! + const existing = select("channel_room", ["room_id", "thread_parent"], {channel_id: channel.id}).get() + if (existing) return existing + + // 2. If the guild is an autocreate guild, it's always okay to bridge to that destination, and + // we'll need to create any dependent resources recursively. + const autocreate = select("guild_active", "autocreate", {guild_id: guildID}).pluck().get() + if (autocreate === 1) return autocreate + + // 3. If the guild is not approved for bridging yet, we can't bridge there. + // They need to decide one way or another whether it's self-service before we can continue. + if (autocreate == null) return autocreate + + // 4. If we got here, the guild is in self-service mode. + // New channels won't be able to create new rooms. But forum threads or channel threads could be fine. + if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { + // In self-service mode, threads rely on the parent resource already existing. + /** @type {DiscordTypes.APIGuildTextChannel} */ // @ts-ignore + const parent = discord.channels.get(channel.parent_id) + assert(parent) + const parentExisting = existsOrAutocreatable(parent, guildID) + if (parentExisting) return 1 // Autocreatable + } + + // 5. If we got here, the guild is in self-service mode and the channel is truly not bridged. + return autocreate +} + +/** + * @param {DiscordTypes.APIGuildTextChannel | DiscordTypes.APIThreadChannel} channel text channel or thread + * @param {string} guildID + * @returns obj if bridged; 1 if autocreatable. (throws if not autocreatable) + */ +function assertExistsOrAutocreatable(channel, guildID) { + const existing = existsOrAutocreatable(channel, guildID) + if (existing === 0) { + throw new Error(`Guild ${guildID} is self-service, so won't create a Matrix room for channel ${channel.id}`) + } + if (!existing) { + throw new Error(`Guild ${guildID} is not bridged, so won't create a Matrix room for channel ${channel.id}`) + } + return existing +} + +/* + Ensure flow: + 1. Get IDs + 2. Does room exist? If so great! + (it doesn't, so it needs to be created) + 3. Get kstate for channel + 4. Create room, return new ID + + Ensure + sync flow: + 1. Get IDs + 2. Does room exist? + 2.5: If room does exist AND wasn't asked to sync: return here + 3. Get kstate for channel + 4. Create room with kstate if room doesn't exist + 5. Get and update room state with kstate if room does exist +*/ + +/** + * Create room and/or sync room data. Please check that a channel_room entry exists or autocreate = 1 before calling this. + * @param {string} channelID + * @param {boolean} shouldActuallySync false if just need to ensure room exists (which is a quick database check), true if also want to sync room data when it does exist (slow) + * @returns {Promise} room ID + */ +async function _syncRoom(channelID, shouldActuallySync) { + /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ + const channel = discord.channels.get(channelID) + assert.ok(channel) + const guild = channelToGuild(channel) + + if (inflightRoomCreate.has(channelID)) { + await inflightRoomCreate.get(channelID) // just waiting, and then doing a new db query afterwards, is the simplest way of doing it + } + + const existing = assertExistsOrAutocreatable(channel, guild.id) + + if (existing === 1) { + const creation = (async () => { + const {spaceID, privacyLevel, channelKState} = await channelToKState(channel, guild, {api}) + const roomID = await createRoom(channel, guild, spaceID, channelKState, privacyLevel) + inflightRoomCreate.delete(channelID) // OK to release inflight waiters now. they will read the correct `existing` row + return roomID + })() + inflightRoomCreate.set(channelID, creation) + return creation // Naturally, the newly created room is already up to date, so we can always skip syncing here. + } + + const roomID = existing.room_id + + if (!shouldActuallySync) { + return existing.room_id // only need to ensure room exists, and it does. return the room ID + } + + console.log(`[room sync] to matrix: ${channel.name}`) + + const {spaceID, channelKState} = await channelToKState(channel, guild, {api}) // calling this in both branches because we don't want to calculate this if not syncing + + // sync channel state to room + const roomKState = await ks.roomToKState(roomID) + if (+roomKState["m.room.create/"].room_version <= 8) { + // join_rule `restricted` is not available in room version < 8 and not working properly in version == 8 + // read more: https://spec.matrix.org/v1.8/rooms/v9/ + // we have to use `public` instead, otherwise the room will be unjoinable. + channelKState["m.room.join_rules/"] = {join_rule: "public"} + } + const roomDiff = ks.diffKState(roomKState, channelKState) + const roomApply = ks.applyKStateDiffToRoom(roomID, roomDiff) + db.prepare("UPDATE channel_room SET name = ? WHERE room_id = ?").run(channel.name, roomID) + + // sync room as space member + const spaceApply = _syncSpaceMember(channel, spaceID, roomID, guild.id) + await Promise.all([roomApply, spaceApply]) + + return roomID +} + +/** Ensures the room exists. If it doesn't, creates the room with an accurate initial state. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ +function ensureRoom(channelID) { + return _syncRoom(channelID, false) +} + +/** Actually syncs. Gets all room state from the homeserver in order to diff, and uploads the icon to mxc if it has changed. Please check that a channel_room entry exists or guild autocreate = 1 before calling this. */ +function syncRoom(channelID) { + return _syncRoom(channelID, true) +} + +async function unbridgeChannel(channelID) { + /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ + const channel = discord.channels.get(channelID) + assert.ok(channel) + assert.ok(channel.guild_id) + return unbridgeDeletedChannel(channel, channel.guild_id) +} + +/** + * @param {{id: string, topic?: string?}} channel channel-ish (just needs an id, topic is optional) + * @param {string} guildID + */ +async function unbridgeDeletedChannel(channel, guildID) { + const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() + assert.ok(roomID) + const row = from("guild_space").join("guild_active", "guild_id").select("space_id", "autocreate").get() + assert.ok(row) + + let botInRoom = true + + // remove declaration that the room is bridged + try { + await api.sendState(roomID, "uk.half-shot.bridge", `moe.cadence.ooye://discord/${guildID}/${channel.id}`, {}) + } catch (e) { + if (String(e).includes("not in room")) { + botInRoom = false + } else { + throw e + } + } + + if (botInRoom && "topic" in channel) { + // previously the Matrix topic would say the channel ID. we should remove that + await api.sendState(roomID, "m.room.topic", "", {topic: channel.topic || ""}) + } + + // delete webhook on discord + const webhook = select("webhook", ["webhook_id", "webhook_token"], {channel_id: channel.id}).get() + if (webhook) { + await discord.snow.webhook.deleteWebhook(webhook.webhook_id, webhook.webhook_token) + db.prepare("DELETE FROM webhook WHERE channel_id = ?").run(channel.id) + } + + // delete room from database + db.prepare("DELETE FROM member_cache WHERE room_id = ?").run(roomID) + db.prepare("DELETE FROM channel_room WHERE room_id = ? AND channel_id = ?").run(roomID, channel.id) // cascades to most other tables, like messages + + if (!botInRoom) return + + // demote admins in room + /** @type {Ty.Event.M_Power_Levels} */ + const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "") + powerLevelContent.users ??= {} + const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}` + for (const mxid of Object.keys(powerLevelContent.users)) { + if (powerLevelContent.users[mxid] >= 100 && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== bot) { + delete powerLevelContent.users[mxid] + await api.sendState(roomID, "m.room.power_levels", "", powerLevelContent, mxid) + } + } + + // send a notification in the room + await api.sendEvent(roomID, "m.room.message", { + msgtype: "m.notice", + body: "⚠️ This room was removed from the bridge." + }) + + // if it is an easy mode room, clean up the room from the managed space and make it clear it's not being bridged + // (don't do this for self-service rooms, because they might continue to be used on Matrix or linked somewhere else later) + if (row.autocreate === 1) { + // remove room from being a space member + await api.sendState(roomID, "m.space.parent", row.space_id, {}) + await api.sendState(row.space_id, "m.space.child", roomID, {}) + } + + // if it is a self-service room, remove sim members + // (the room can be used with less clutter and the member list makes sense if it's bridged somewhere else) + if (row.autocreate === 0) { + // remove sim members + const members = db.prepare("SELECT mxid FROM sim_member WHERE room_id = ? AND mxid <> ?").pluck().all(roomID, bot) + const preparedDelete = db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?") + for (const mxid of members) { + await api.leaveRoom(roomID, mxid) + preparedDelete.run(roomID, mxid) + } + } + + // leave room + await api.leaveRoom(roomID) +} + +/** + * Async because it gets all space state from the homeserver, then if necessary sends one state event back. + * @param {DiscordTypes.APIGuildTextChannel} channel + * @param {string} spaceID + * @param {string} roomID + * @param {string} guild_id + * @returns {Promise} + */ +async function _syncSpaceMember(channel, spaceID, roomID, guild_id) { + // If space is self-service then only permit changes to space parenting for threads + // (The person setting up self-service has already put it in their preferred space to be able to get this far.) + const autocreate = select("guild_active", "autocreate", {guild_id}).pluck().get() + if (autocreate === 0 && ![DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) { + return [] + } + + const spaceKState = await ks.roomToKState(spaceID) + let spaceEventContent = {} + if ( + channel.type !== DiscordTypes.ChannelType.PrivateThread // private threads do not belong in the space (don't offer people something they can't join) + && ( + !channel["thread_metadata"]?.archived // archived threads do not belong in the space (don't offer people conversations that are no longer relevant) + || discord.channels.get(channel.parent_id || "")?.type === DiscordTypes.ChannelType.GuildForum + ) + ) { + spaceEventContent = { + via: [reg.ooye.server_name] + } + } + const spaceDiff = ks.diffKState(spaceKState, { + [`m.space.child/${roomID}`]: spaceEventContent + }) + return ks.applyKStateDiffToRoom(spaceID, spaceDiff) +} + +async function createAllForGuild(guildID) { + const channelIDs = discord.guildChannelMap.get(guildID) + assert.ok(channelIDs) + for (const channelID of channelIDs) { + const allowedTypes = [DiscordTypes.ChannelType.GuildText, DiscordTypes.ChannelType.PublicThread] + // @ts-ignore + if (allowedTypes.includes(discord.channels.get(channelID)?.type)) { + const roomID = await syncRoom(channelID) + console.log(`synced ${channelID} <-> ${roomID}`) + } + } +} + +module.exports.DEFAULT_PRIVACY_LEVEL = DEFAULT_PRIVACY_LEVEL +module.exports.READ_ONLY_ROOM_EVENTS_DEFAULT_POWER = READ_ONLY_ROOM_EVENTS_DEFAULT_POWER +module.exports.PRIVACY_ENUMS = PRIVACY_ENUMS +module.exports.createRoom = createRoom +module.exports.ensureRoom = ensureRoom +module.exports.syncRoom = syncRoom +module.exports.createAllForGuild = createAllForGuild +module.exports.channelToKState = channelToKState +module.exports.postApplyPowerLevels = postApplyPowerLevels +module.exports._convertNameAndTopic = convertNameAndTopic +module.exports.unbridgeChannel = unbridgeChannel +module.exports.unbridgeDeletedChannel = unbridgeDeletedChannel +module.exports.existsOrAutocreatable = existsOrAutocreatable +module.exports.assertExistsOrAutocreatable = assertExistsOrAutocreatable diff --git a/src/d2m/actions/create-room.test.js b/src/d2m/actions/create-room.test.js new file mode 100644 index 0000000..e653744 --- /dev/null +++ b/src/d2m/actions/create-room.test.js @@ -0,0 +1,247 @@ +// @ts-check + +const mixin = require("@cloudrac3r/mixin-deep") +const {channelToKState, _convertNameAndTopic} = require("./create-room") +const {kstateStripConditionals} = require("../../matrix/kstate") +const {test} = require("supertape") +const testData = require("../../../test/data") + +const passthrough = require("../../passthrough") +const {db} = passthrough + + +test("channel2room: discoverable privacy room", async t => { + let called = 0 + async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users: {"@example:matrix.org": 50}} + } + db.prepare("UPDATE guild_space SET privacy_level = 2").run() + t.deepEqual( + kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)), + Object.assign({}, testData.room.general, { + "m.room.guest_access/": {guest_access: "forbidden"}, + "m.room.join_rules/": {join_rule: "public"}, + "m.room.history_visibility/": {history_visibility: "world_readable"}, + "m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"]) + }) + ) + t.equal(called, 1) +}) + +test("channel2room: linkable privacy room", async t => { + let called = 0 + async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users: {"@example:matrix.org": 50}} + } + db.prepare("UPDATE guild_space SET privacy_level = 1").run() + t.deepEqual( + kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)), + Object.assign({}, testData.room.general, { + "m.room.guest_access/": {guest_access: "forbidden"}, + "m.room.join_rules/": {join_rule: "public"}, + "m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"]) + }) + ) + t.equal(called, 1) +}) + +test("channel2room: invite-only privacy room", async t => { + let called = 0 + async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users: {"@example:matrix.org": 50}} + } + db.prepare("UPDATE guild_space SET privacy_level = 0").run() + t.deepEqual( + kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)), + Object.assign({}, testData.room.general, { + "m.room.power_levels/": mixin({users: {"@example:matrix.org": 50}}, testData.room.general["m.room.power_levels/"]) + }) + ) + t.equal(called, 1) +}) + +test("channel2room: room where limited people can mention everyone", async t => { + let called = 0 + async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {users: {"@example:matrix.org": 50}} + } + const limitedGuild = mixin({}, testData.guild.general) + limitedGuild.roles[0].permissions = (BigInt(limitedGuild.roles[0].permissions) - 131072n).toString() + const limitedRoom = mixin({}, testData.room.general, {"m.room.power_levels/": { + notifications: {room: 20}, + users: {"@example:matrix.org": 50} + }}) + t.deepEqual( + kstateStripConditionals(await channelToKState(testData.channel.general, limitedGuild, {api: {getStateEvent}}).then(x => x.channelKState)), + limitedRoom + ) + t.equal(called, 1) +}) + +test("channel2room: matrix room that already has a custom topic set", async t => { + let called = 0 + async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {} + } + db.prepare("UPDATE channel_room SET custom_topic = 1 WHERE channel_id = ?").run(testData.channel.general.id) + const expected = mixin({}, testData.room.general, {"m.room.power_levels/": {notifications: {room: 20}}}) + // @ts-ignore + delete expected["m.room.topic/"] + t.deepEqual( + kstateStripConditionals(await channelToKState(testData.channel.general, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)), + expected + ) + t.equal(called, 1) +}) + +test("channel2room: read-only discord channel", async t => { + let called = 0 + async function getStateEvent(roomID, type, key) { // getting power levels from space to apply to room + called++ + t.equal(roomID, "!jjmvBegULiLucuWEHU:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {} + } + const expected = { + "chat.schildi.hide_ui/read_receipts": {}, + "m.room.avatar/": { + url: { + $url: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024", + }, + }, + "m.room.guest_access/": { + guest_access: "can_join", + }, + "m.room.history_visibility/": { + history_visibility: "shared", + }, + "m.room.join_rules/": { + allow: [ + { + room_id: "!jjmvBegULiLucuWEHU:cadence.moe", + type: "m.room_membership", + }, + ], + join_rule: "restricted", + }, + "m.room.name/": { + name: "updates", + }, + "m.room.topic/": { + topic: "Updates and release announcements for Out Of Your Element.\n\nChannel ID: 1161864271370666075\nGuild ID: 112760669178241024" + }, + "m.room.power_levels/": { + events_default: 50, // <-- it should be read-only! + events: { + "m.reaction": 0, + "m.room.redaction": 0 + }, + notifications: { + room: 20, + }, + users: { + "@test_auto_invite:example.org": 100, + }, + }, + "m.space.parent/!jjmvBegULiLucuWEHU:cadence.moe": { + canonical: true, + via: [ + "cadence.moe", + ], + }, + "uk.half-shot.bridge/moe.cadence.ooye://discord/112760669178241024/1161864271370666075": { + bridgebot: "@_ooye_bot:cadence.moe", + channel: { + displayname: "updates", + external_url: "https://discord.com/channels/112760669178241024/1161864271370666075", + id: "1161864271370666075", + }, + network: { + avatar_url: { + "$url": "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024", + }, + displayname: "Psychonauts 3", + id: "112760669178241024", + }, + protocol: { + displayname: "Discord", + id: "discord", + } + } + } + t.deepEqual( + kstateStripConditionals(await channelToKState(testData.channel.updates, testData.guild.general, {api: {getStateEvent}}).then(x => x.channelKState)), + expected + ) + t.equal(called, 1) +}) + +test("convertNameAndTopic: custom name and topic", t => { + t.deepEqual( + _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, "hauntings"), + ["hauntings", "#the-twilight-zone | Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] + ) +}) + +test("convertNameAndTopic: custom name, no topic", t => { + t.deepEqual( + _convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, "hauntings"), + ["hauntings", "#the-twilight-zone\n\nChannel ID: 123\nGuild ID: 456"] + ) +}) + +test("convertNameAndTopic: original name and topic", t => { + t.deepEqual( + _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 0}, {id: "456"}, null), + ["the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] + ) +}) + +test("convertNameAndTopic: original name, no topic", t => { + t.deepEqual( + _convertNameAndTopic({id: "123", name: "the-twilight-zone", type: 0}, {id: "456"}, null), + ["the-twilight-zone", "Channel ID: 123\nGuild ID: 456"] + ) +}) + +test("convertNameAndTopic: public thread icon", t => { + t.deepEqual( + _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 11}, {id: "456"}, null), + ["[⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] + ) +}) + +test("convertNameAndTopic: private thread icon", t => { + t.deepEqual( + _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 12}, {id: "456"}, null), + ["[🔒⛓️] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] + ) +}) + +test("convertNameAndTopic: voice channel icon", t => { + t.deepEqual( + _convertNameAndTopic({id: "123", name: "the-twilight-zone", topic: "Spooky stuff here. :ghost:", type: 2}, {id: "456"}, null), + ["[🔊] the-twilight-zone", "Spooky stuff here. :ghost:\n\nChannel ID: 123\nGuild ID: 456"] + ) +}) diff --git a/d2m/actions/create-space.js b/src/d2m/actions/create-space.js similarity index 63% rename from d2m/actions/create-space.js rename to src/d2m/actions/create-space.js index b30f681..8bce3ad 100644 --- a/d2m/actions/create-space.js +++ b/src/d2m/actions/create-space.js @@ -1,8 +1,10 @@ // @ts-check const assert = require("assert").strict +const {isDeepStrictEqual} = require("util") const DiscordTypes = require("discord-api-types/v10") -const reg = require("../../matrix/read-registration") +const Ty = require("../../types") +const {reg} = require("../../matrix/read-registration") const passthrough = require("../../passthrough") const {discord, sync, db, select} = passthrough @@ -12,8 +14,8 @@ const api = sync.require("../../matrix/api") const file = sync.require("../../matrix/file") /** @type {import("./create-room")} */ const createRoom = sync.require("./create-room") -/** @type {import("../converters/expression")} */ -const expression = sync.require("../converters/expression") +/** @type {import("./expression")} */ +const expression = sync.require("./expression") /** @type {import("../../matrix/kstate")} */ const ks = sync.require("../../matrix/kstate") @@ -21,7 +23,7 @@ const ks = sync.require("../../matrix/kstate") const inflightSpaceCreate = new Map() /** - * @param {import("discord-api-types/v10").RESTGetAPIGuildResult} guild + * @param {DiscordTypes.RESTGetAPIGuildResult} guild * @param {any} kstate */ async function createSpace(guild, kstate) { @@ -29,6 +31,10 @@ async function createSpace(guild, kstate) { const topic = kstate["m.room.topic/"]?.topic || undefined assert(name) + const memberCount = guild["member_count"] ?? guild.approximate_member_count ?? 0 + const enablePresenceByDefault = +(memberCount < 50) // scary! all active users in a presence-enabled guild will be pinging the server every <30 seconds to stay online + const globalAdmins = select("member_power", "mxid", {room_id: "*"}).pluck().all() + const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => { return api.createRoom({ name, @@ -38,15 +44,15 @@ async function createSpace(guild, kstate) { events_default: 100, // space can only be managed by bridge invite: 0 // any existing member can invite others }, - invite: reg.ooye.invite, + invite: globalAdmins, topic, creation_content: { type: "m.space" }, - initial_state: ks.kstateToState(kstate) + initial_state: await ks.kstateToState(kstate) }) }) - db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guild.id, roomID) + db.prepare("INSERT INTO guild_space (guild_id, space_id, presence) VALUES (?, ?, ?)").run(guild.id, roomID, enablePresenceByDefault) return roomID } @@ -55,19 +61,18 @@ async function createSpace(guild, kstate) { * @param {number} privacyLevel */ async function guildToKState(guild, privacyLevel) { - const avatarEventContent = {} - if (guild.icon) { - avatarEventContent.discord_path = file.guildIcon(guild) - avatarEventContent.url = await file.uploadDiscordFileToMxc(avatarEventContent.discord_path) // TODO: somehow represent future values in kstate (callbacks?), while still allowing for diffing, so test cases don't need to touch the media API - } - + assert.equal(typeof privacyLevel, "number") + const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all() const guildKState = { "m.room.name/": {name: guild.name}, - "m.room.avatar/": avatarEventContent, + "m.room.avatar/": { + $if: guild.icon, + url: {$url: file.guildIcon(guild)} + }, "m.room.guest_access/": {guest_access: createRoom.PRIVACY_ENUMS.GUEST_ACCESS[privacyLevel]}, "m.room.history_visibility/": {history_visibility: createRoom.PRIVACY_ENUMS.SPACE_HISTORY_VISIBILITY[privacyLevel]}, "m.room.join_rules/": {join_rule: createRoom.PRIVACY_ENUMS.SPACE_JOIN_RULES[privacyLevel]}, - "m.room.power_levels/": {users: reg.ooye.invite.reduce((a, c) => (a[c] = 100, a), {})} + "m.room.power_levels/": {users: globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {})} // used in guild initial creation postApplyPowerLevels } return guildKState @@ -89,6 +94,9 @@ async function _syncSpace(guild, shouldActuallySync) { const row = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get() if (!row) { + const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get() + assert.equal(autocreate, 1, `refusing to implicitly create a space for guild ${guild.id}. set the guild_active data first before calling ensureSpace/syncSpace.`) + const creation = (async () => { const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL) // New spaces will have to use the default privacy level; we obviously can't look up the existing entry const spaceID = await createSpace(guild, guildKState) @@ -110,9 +118,9 @@ async function _syncSpace(guild, shouldActuallySync) { const guildKState = await guildToKState(guild, privacy_level) // calling this in both branches because we don't want to calculate this if not syncing // sync guild state to space - const spaceKState = await createRoom.roomToKState(spaceID) + const spaceKState = await ks.roomToKState(spaceID) const spaceDiff = ks.diffKState(spaceKState, guildKState) - await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff) + await ks.applyKStateDiffToRoom(spaceID, spaceDiff) // guild icon was changed, so room avatars need to be updated as well as the space ones // doing it this way rather than calling syncRoom for great efficiency gains @@ -121,15 +129,10 @@ async function _syncSpace(guild, shouldActuallySync) { // don't try to update rooms with custom avatars though const roomsWithCustomAvatars = select("channel_room", "room_id", {}, "WHERE custom_avatar IS NOT NULL").pluck().all() - const childRooms = ks.kstateToState(spaceKState).filter(({type, state_key, content}) => { - return type === "m.space.child" && "via" in content && !roomsWithCustomAvatars.includes(state_key) - }).map(({state_key}) => state_key) - - for (const roomID of childRooms) { - const avatarEventContent = await api.getStateEvent(roomID, "m.room.avatar", "") - if (avatarEventContent.url !== newAvatarState.url) { - await api.sendState(roomID, "m.room.avatar", "", newAvatarState) - } + for await (const room of api.generateFullHierarchy(spaceID)) { + if (room.avatar_url === newAvatarState.url) continue + if (roomsWithCustomAvatars.includes(room.room_id)) continue + await api.sendState(room.room_id, "m.room.avatar", "", newAvatarState) } } @@ -176,21 +179,19 @@ async function syncSpaceFully(guildID) { const guildKState = await guildToKState(guild, privacy_level) // sync guild state to space - const spaceKState = await createRoom.roomToKState(spaceID) + const spaceKState = await ks.roomToKState(spaceID) const spaceDiff = ks.diffKState(spaceKState, guildKState) - await createRoom.applyKStateDiffToRoom(spaceID, spaceDiff) + await ks.applyKStateDiffToRoom(spaceID, spaceDiff) - const childRooms = ks.kstateToState(spaceKState).filter(({type, content}) => { - return type === "m.space.child" && "via" in content - }).map(({state_key}) => state_key) + const childRooms = await api.getFullHierarchy(spaceID) - for (const roomID of childRooms) { - const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get() + for (const {room_id} of childRooms) { + const channelID = select("channel_room", "channel_id", {room_id}).pluck().get() if (!channelID) continue if (discord.channels.has(channelID)) { await createRoom.syncRoom(channelID) } else { - await createRoom.unbridgeDeletedChannel(channelID, guildID) + await createRoom.unbridgeDeletedChannel({id: channelID}, guildID) } } @@ -198,23 +199,40 @@ async function syncSpaceFully(guildID) { } /** - * @param {import("discord-api-types/v10").GatewayGuildEmojisUpdateDispatchData | import("discord-api-types/v10").GatewayGuildStickersUpdateDispatchData} data + * @param {DiscordTypes.GatewayGuildEmojisUpdateDispatchData | DiscordTypes.GatewayGuildStickersUpdateDispatchData} data + * @param {boolean} checkBeforeSync false to always send new state, true to check the current state and only apply if state would change */ -async function syncSpaceExpressions(data) { +async function syncSpaceExpressions(data, checkBeforeSync) { // No need for kstate here. Each of these maps to a single state event, which will always overwrite what was there before. I can just send the state event. const spaceID = select("guild_space", "space_id", {guild_id: data.guild_id}).pluck().get() if (!spaceID) return - if ("emojis" in data && data.emojis.length) { - const content = await expression.emojisToState(data.emojis) - api.sendState(spaceID, "im.ponies.room_emotes", "moe.cadence.ooye.pack.emojis", content) + /** + * @typedef {DiscordTypes.GatewayGuildEmojisUpdateDispatchData & DiscordTypes.GatewayGuildStickersUpdateDispatchData} Expressions + * @param {string} spaceID + * @param {Expressions extends any ? keyof Expressions : never} key + * @param {string} eventKey + * @param {typeof expression["emojisToState"] | typeof expression["stickersToState"]} fn + */ + async function update(spaceID, key, eventKey, fn) { + if (!(key in data) || !data[key].length) return + const content = await fn(data[key]) + if (checkBeforeSync) { + let existing + try { + existing = await api.getStateEvent(spaceID, "im.ponies.room_emotes", eventKey) + } catch (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([]) + } + if (isDeepStrictEqual(existing, content)) return + } + await api.sendState(spaceID, "im.ponies.room_emotes", eventKey, content) } - if ("stickers" in data && data.stickers.length) { - const content = await expression.stickersToState(data.stickers) - api.sendState(spaceID, "im.ponies.room_emotes", "moe.cadence.ooye.pack.stickers", content) - } + await update(spaceID, "emojis", "moe.cadence.ooye.pack.emojis", expression.emojisToState) + await update(spaceID, "stickers", "moe.cadence.ooye.pack.stickers", expression.stickersToState) } module.exports.createSpace = createSpace diff --git a/src/d2m/actions/create-space.test.js b/src/d2m/actions/create-space.test.js new file mode 100644 index 0000000..cb4d90a --- /dev/null +++ b/src/d2m/actions/create-space.test.js @@ -0,0 +1,38 @@ +// @ts-check + +const mixin = require("@cloudrac3r/mixin-deep") +const {guildToKState, ensureSpace} = require("./create-space") +const {kstateStripConditionals, kstateUploadMxc} = require("../../matrix/kstate") +const {test} = require("supertape") +const testData = require("../../../test/data") + +const passthrough = require("../../passthrough") +const {db} = passthrough + +test("guild2space: can generate kstate for a guild, passing privacy level 0", async t => { + t.deepEqual( + await kstateUploadMxc(kstateStripConditionals(await guildToKState(testData.guild.general, 0))), + { + "m.room.avatar/": { + url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF" + }, + "m.room.guest_access/": { + guest_access: "can_join" + }, + "m.room.history_visibility/": { + history_visibility: "invited" + }, + "m.room.join_rules/": { + join_rule: "invite" + }, + "m.room.name/": { + name: "Psychonauts 3" + }, + "m.room.power_levels/": { + users: { + "@test_auto_invite:example.org": 100 + }, + }, + } + ) +}) diff --git a/src/d2m/actions/delete-message.js b/src/d2m/actions/delete-message.js new file mode 100644 index 0000000..e9e0b08 --- /dev/null +++ b/src/d2m/actions/delete-message.js @@ -0,0 +1,44 @@ +// @ts-check + +const passthrough = require("../../passthrough") +const {sync, db, select, from} = passthrough +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("./speedbump")} */ +const speedbump = sync.require("./speedbump") + +/** + * @param {import("discord-api-types/v10").GatewayMessageDeleteDispatchData} data + */ +async function deleteMessage(data) { + const row = select("channel_room", ["room_id", "speedbump_checked", "thread_parent"], {channel_id: data.channel_id}).get() + if (!row) return + + const eventsToRedact = select("event_message", "event_id", {message_id: data.id}).pluck().all() + db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(data.id) + for (const eventID of eventsToRedact) { + // Unfortunately, we can't specify a sender to do the redaction as, unless we find out that info via the audit logs + await api.redactEvent(row.room_id, eventID) + } + + await speedbump.updateCache(row.thread_parent || data.channel_id, row.speedbump_checked) +} + +/** + * @param {import("discord-api-types/v10").GatewayMessageDeleteBulkDispatchData} data + */ +async function deleteMessageBulk(data) { + const roomID = select("channel_room", "room_id", {channel_id: data.channel_id}).pluck().get() + if (!roomID) return + + const sids = JSON.stringify(data.ids) + const eventsToRedact = from("event_message").pluck("event_id").and("WHERE message_id IN (SELECT value FROM json_each(?))").all(sids) + db.prepare("DELETE FROM message_channel WHERE message_id IN (SELECT value FROM json_each(?))").run(sids) + for (const eventID of eventsToRedact) { + // Awaiting will make it go slower, but since this could be a long-running operation either way, we want to leave rate limit capacity for other operations + await api.redactEvent(roomID, eventID) + } +} + +module.exports.deleteMessage = deleteMessage +module.exports.deleteMessageBulk = deleteMessageBulk diff --git a/d2m/actions/edit-message.js b/src/d2m/actions/edit-message.js similarity index 73% rename from d2m/actions/edit-message.js rename to src/d2m/actions/edit-message.js index 2a08526..1afcb35 100644 --- a/d2m/actions/edit-message.js +++ b/src/d2m/actions/edit-message.js @@ -1,18 +1,30 @@ // @ts-check +const assert = require("assert").strict + const passthrough = require("../../passthrough") const {sync, db, select} = passthrough /** @type {import("../converters/edit-to-changes")} */ const editToChanges = sync.require("../converters/edit-to-changes") +/** @type {import("./register-pk-user")} */ +const registerPkUser = sync.require("./register-pk-user") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") /** * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message * @param {import("discord-api-types/v10").APIGuild} guild + * @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel */ -async function editMessage(message, guild) { - const {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api) +async function editMessage(message, guild, row) { + let {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api) + + if (row && row.speedbump_webhook_id === message.webhook_id) { + // Handle the PluralKit public instance + if (row.speedbump_id === "466378653216014359") { + senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, false) + } + } // 1. Replace all the things. for (const {oldID, newContent} of eventsToReplace) { @@ -39,7 +51,7 @@ async function editMessage(message, guild) { const sendNewEventParts = new Set() for (const promotion of promotions) { if ("eventID" in promotion) { - db.prepare(`UPDATE event_message SET ${promotion.column} = 0 WHERE event_id = ?`).run(promotion.eventID) + db.prepare(`UPDATE event_message SET ${promotion.column} = ? WHERE event_id = ?`).run(promotion.value ?? 0, promotion.eventID) } else if ("nextEvent" in promotion) { sendNewEventParts.add(promotion.column) } @@ -47,7 +59,7 @@ async function editMessage(message, guild) { // 4. Send all the things. if (eventsToSend.length) { - db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) + db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) } for (const content of eventsToSend) { const eventType = content.$type diff --git a/d2m/converters/expression.js b/src/d2m/actions/expression.js similarity index 86% rename from d2m/converters/expression.js rename to src/d2m/actions/expression.js index 1c52c98..fd75aa5 100644 --- a/d2m/converters/expression.js +++ b/src/d2m/actions/expression.js @@ -1,10 +1,9 @@ // @ts-check -const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../../passthrough") -const {discord, sync, db, select} = passthrough +const {sync, db} = passthrough /** @type {import("../../matrix/file")} */ const file = sync.require("../../matrix/file") @@ -31,7 +30,7 @@ async function emojisToState(emojis) { } db.prepare("INSERT OR IGNORE INTO emoji (emoji_id, name, animated, mxc_url) VALUES (?, ?, ?, ?)").run(emoji.id, emoji.name, +!!emoji.animated, url) }).catch(e => { - if (e.data.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit. + if (e.data?.errcode === "M_TOO_LARGE") { // Very unlikely to happen. Only possible for 3x-series emojis uploaded shortly after animated emojis were introduced, when there was no 256 KB size limit. return } console.error(`Trying to handle emoji ${emoji.name} (${emoji.id}), but...`) @@ -67,7 +66,7 @@ async function stickersToState(stickers) { while (shortcodes.includes(shortcode)) shortcode = shortcode + "~" shortcodes.push(shortcode) - result.images[shortcodes] = { + result.images[shortcode] = { info: { mimetype: file.stickerFormat.get(sticker.format_type)?.mime || "image/png" }, diff --git a/d2m/converters/lottie.js b/src/d2m/actions/lottie.js similarity index 52% rename from d2m/converters/lottie.js rename to src/d2m/actions/lottie.js index a0d1cd1..0185980 100644 --- a/d2m/converters/lottie.js +++ b/src/d2m/actions/lottie.js @@ -3,63 +3,40 @@ const DiscordTypes = require("discord-api-types/v10") const Ty = require("../../types") const assert = require("assert").strict -const {PNG} = require("pngjs") const passthrough = require("../../passthrough") -const {sync, db, discord, select} = passthrough +const {sync, db, select} = passthrough /** @type {import("../../matrix/file")} */ const file = sync.require("../../matrix/file") -//** @type {import("../../matrix/mreq")} */ +/** @type {import("../../matrix/mreq")} */ const mreq = sync.require("../../matrix/mreq") - -const SIZE = 160 // Discord's display size on 1x displays is 160 +/** @type {import("../converters/lottie")} */ +const convertLottie = sync.require("../converters/lottie") const INFO = { mimetype: "image/png", - w: SIZE, - h: SIZE + w: convertLottie.SIZE, + h: convertLottie.SIZE } -/** - * @typedef RlottieWasm - * @prop {(string) => boolean} load load lottie data from string of json - * @prop {() => number} frames get number of frames - * @prop {(frameCount: number, width: number, height: number) => Uint8Array} render render lottie data to bitmap - */ - -const Rlottie = (async () => { - const Rlottie = require("./rlottie-wasm.js") - await new Promise(resolve => Rlottie.onRuntimeInitialized = resolve) - return Rlottie -})() - /** * @param {DiscordTypes.APIStickerItem} stickerItem * @returns {Promise<{mxc_url: string, info: typeof INFO}>} */ async function convert(stickerItem) { + // Reuse sticker if already converted and uploaded const existingMxc = select("lottie", "mxc_url", {sticker_id: stickerItem.id}).pluck().get() if (existingMxc) return {mxc_url: existingMxc, info: INFO} - const r = await Rlottie + + // Fetch sticker data from Discord const res = await fetch(file.DISCORD_IMAGES_BASE + file.sticker(stickerItem)) if (res.status !== 200) throw new Error("Sticker data file not found.") const text = await res.text() - /** @type RlottieWasm */ - const rh = new r.RlottieWasm() - const status = rh.load(text) - if (!status) throw new Error(`Rlottie unable to load ${text.length} byte data file.`) - const rendered = rh.render(0, SIZE, SIZE) - let png = new PNG({ - width: SIZE, - height: SIZE, - bitDepth: 8, // 8 red + 8 green + 8 blue + 8 alpha - colorType: 6, // RGBA - inputColorType: 6, // RGBA - inputHasAlpha: true, - }) - png.data = Buffer.from(rendered) - // @ts-ignore wrong type from pngjs - const readablePng = png.pack() + + // Convert to PNG (stream.Readable) + const readablePng = await convertLottie.convert(text) + + // Upload to MXC /** @type {Ty.R.FileUploaded} */ const root = await mreq.mreq("POST", "/media/v3/upload", readablePng, { headers: { @@ -67,6 +44,8 @@ async function convert(stickerItem) { } }) assert(root.content_uri) + + // Save the link for next time db.prepare("INSERT INTO lottie (sticker_id, mxc_url) VALUES (?, ?)").run(stickerItem.id, root.content_uri) return {mxc_url: root.content_uri, info: INFO} } diff --git a/src/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js new file mode 100644 index 0000000..27e949c --- /dev/null +++ b/src/d2m/actions/register-pk-user.js @@ -0,0 +1,176 @@ +// @ts-check + +const assert = require("assert") +const {reg} = require("../../matrix/read-registration") +const Ty = require("../../types") + +const passthrough = require("../../passthrough") +const {sync, db, select, from} = passthrough +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("../../matrix/file")} */ +const file = sync.require("../../matrix/file") +/** @type {import("./register-user")} */ +const registerUser = sync.require("./register-user") + +/** + * @typedef WebhookAuthor Discord API message->author. A webhook as an author. + * @prop {string} username + * @prop {string?} avatar + * @prop {string} id + */ + +/** @returns {Promise} */ +async function fetchMessage(messageID) { + try { + var res = await fetch(`https://api.pluralkit.me/v2/messages/${messageID}`) + } catch (networkError) { + // Network issue, raise a more readable message + throw new Error(`Failed to connect to PK API: ${networkError.toString()}`) + } + if (!res.ok) throw new Error(`PK API returned an error: ${await res.text()}`) + const root = await res.json() + if (!root.member) throw new Error(`PK API didn't return member data: ${JSON.stringify(root)}`) + return root +} + +/** + * A sim is an account that is being simulated by the bridge to copy events from the other side. + * @param {Ty.PkMessage} pkMessage + * @returns mxid + */ +async function createSim(pkMessage) { + // Choose sim name + const simName = "_pk_" + pkMessage.member.id + const localpart = reg.ooye.namespace_prefix + simName + const mxid = `@${localpart}:${reg.ooye.server_name}` + + // Save chosen name in the database forever + db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, simName, simName, mxid) + + // Register matrix user with that name + try { + await api.register(localpart) + } catch (e) { + // If user creation fails, manually undo the database change. Still isn't perfect, but should help. + // (I would prefer a transaction, but it's not safe to leave transactions open across event loop ticks.) + db.prepare("DELETE FROM sim WHERE user_id = ?").run(pkMessage.member.uuid) + throw e + } + return mxid +} + +/** + * Ensure a sim is registered for the user. + * If there is already a sim, use that one. If there isn't one yet, register a new sim. + * @param {Ty.PkMessage} pkMessage + * @returns {Promise} mxid + */ +async function ensureSim(pkMessage) { + let mxid = null + const existing = select("sim", "mxid", {user_id: pkMessage.member.uuid}).pluck().get() + if (existing) { + mxid = existing + } else { + mxid = await createSim(pkMessage) + } + return mxid +} + +/** + * Ensure a sim is registered for the user and is joined to the room. + * @param {Ty.PkMessage} pkMessage + * @param {string} roomID + * @returns {Promise} mxid + */ +async function ensureSimJoined(pkMessage, roomID) { + // Ensure room ID is really an ID, not an alias + assert.ok(roomID[0] === "!") + + // Ensure user + const mxid = await ensureSim(pkMessage) + + // Ensure joined + const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get() + if (!existing) { + try { + await api.inviteToRoom(roomID, mxid) + await api.joinRoom(roomID, mxid) + } catch (e) { + if (e.message.includes("is already in the room.")) { + // Sweet! + } else { + throw e + } + } + db.prepare("INSERT OR IGNORE INTO sim_member (room_id, mxid) VALUES (?, ?)").run(roomID, mxid) + } + return mxid +} + +/** + * Generate profile data based on webhook displayname and configured avatar. + * @param {Ty.PkMessage} pkMessage + * @param {WebhookAuthor} author + */ +async function memberToStateContent(pkMessage, author) { + // We prefer to use the member's avatar URL data since the image upload can be cached across channels, + // unlike the userAvatar URL which is unique per channel, due to the webhook ID being in the URL. + const avatar = pkMessage.member.avatar_url || pkMessage.member.webhook_avatar_url || pkMessage.system.avatar_url || file.userAvatar(author) + + const content = { + displayname: author.username, + membership: "join", + "moe.cadence.ooye.pk_member": pkMessage.member + } + if (avatar) content.avatar_url = await file.uploadDiscordFileToMxc(avatar) + + return content +} + +/** + * Sync profile data for a sim user. This function follows the following process: + * 1. Look up data about proxy user from API + * 2. If this fails, try to use previously cached data (won't sync) + * 3. Create and join the sim to the room if needed + * 4. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before + * 5. Compare against the previously known state content, which is helpfully stored in the database + * 6. If the state content has changed, send it to Matrix and update it in the database for next time + * @param {string} messageID to call API with + * @param {WebhookAuthor} author for profile data + * @param {string} roomID room to join member to + * @param {boolean} shouldActuallySync whether to actually sync updated user data or just ensure it's joined + * @returns {Promise} mxid of the updated sim + */ +async function syncUser(messageID, author, roomID, shouldActuallySync) { + try { + // API lookup + var pkMessage = await fetchMessage(messageID) + db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username) + } catch (e) { + // Fall back to offline cache + const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID}).pluck("mxid").get() + if (!senderMxid) throw e + return senderMxid + } + + // Create and join the sim to the room if needed + const mxid = await ensureSimJoined(pkMessage, roomID) + + if (shouldActuallySync) { + // Build current profile data + const content = await memberToStateContent(pkMessage, author) + const currentHash = registerUser._hashProfileContent(content, 0) + const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() + + // Only do the actual sync if the hash has changed since we last looked + if (existingHash !== currentHash) { + await api.sendState(roomID, "m.room.member", mxid, content, mxid) + db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) + } + } + + return mxid +} + +module.exports.syncUser = syncUser diff --git a/src/d2m/actions/register-user.js b/src/d2m/actions/register-user.js new file mode 100644 index 0000000..674853a --- /dev/null +++ b/src/d2m/actions/register-user.js @@ -0,0 +1,258 @@ +// @ts-check + +const assert = require("assert").strict +const {reg} = require("../../matrix/read-registration") +const DiscordTypes = require("discord-api-types/v10") +const Ty = require("../../types") +const mixin = require("@cloudrac3r/mixin-deep") + +const passthrough = require("../../passthrough") +const {discord, sync, db, select} = passthrough +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("../../matrix/file")} */ +const file = sync.require("../../matrix/file") +/** @type {import("../../discord/utils")} */ +const utils = sync.require("../../discord/utils") +/** @type {import("../converters/user-to-mxid")} */ +const userToMxid = sync.require("../converters/user-to-mxid") +/** @type {import("./create-room")} */ +const createRoom = sync.require("./create-room") +/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore +let hasher = null +// @ts-ignore +require("xxhash-wasm")().then(h => hasher = h) + +/** + * A sim is an account that is being simulated by the bridge to copy events from the other side. + * @param {DiscordTypes.APIUser} user + * @returns mxid + */ +async function createSim(user) { + // Choose sim name + const simName = userToMxid.userToSimName(user) + const localpart = reg.ooye.namespace_prefix + simName + const mxid = `@${localpart}:${reg.ooye.server_name}` + + // Save chosen name in the database forever + // Making this database change right away so that in a concurrent registration, the 2nd registration will already have generated a different localpart because it can see this row when it generates + db.prepare("INSERT INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(user.id, user.username, simName, mxid) + + // Register matrix user with that name + try { + await api.register(localpart) + } catch (e) { + // If user creation fails, manually undo the database change. Still isn't perfect, but should help. + // (I would prefer a transaction, but it's not safe to leave transactions open across event loop ticks.) + db.prepare("DELETE FROM sim WHERE user_id = ?").run(user.id) + throw e + } + return mxid +} + +/** + * Ensure a sim is registered for the user. + * If there is already a sim, use that one. If there isn't one yet, register a new sim. + * @param {DiscordTypes.APIUser} user + * @returns {Promise} mxid + */ +async function ensureSim(user) { + let mxid = null + const existing = select("sim", "mxid", {user_id: user.id}).pluck().get() + if (existing) { + mxid = existing + } else { + mxid = await createSim(user) + } + return mxid +} + +/** + * Ensure a sim is registered for the user and is joined to the room. + * @param {DiscordTypes.APIUser} user + * @param {string} roomID + * @returns {Promise} mxid + */ +async function ensureSimJoined(user, roomID) { + // Ensure room ID is really an ID, not an alias + assert.ok(roomID[0] === "!") + + // Ensure user + const mxid = await ensureSim(user) + + // Ensure joined + const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get() + if (!existing) { + try { + await api.inviteToRoom(roomID, mxid) + await api.joinRoom(roomID, mxid) + } catch (e) { + if (e.message.includes("is already in the room.")) { + // Sweet! + } else { + throw e + } + } + db.prepare("INSERT OR IGNORE INTO sim_member (room_id, mxid) VALUES (?, ?)").run(roomID, mxid) + } + return mxid +} + +/** + * @param {DiscordTypes.APIUser} user + * @param {Omit | undefined} member + */ +async function memberToStateContent(user, member, guildID) { + let displayname = user.username + if (user.global_name) displayname = user.global_name + if (member?.nick) displayname = member.nick + + const content = { + displayname, + membership: "join", + "moe.cadence.ooye.member": { + }, + "uk.half-shot.discord.member": { + bot: !!user.bot, + displayColor: user.accent_color, + id: user.id, + username: user.discriminator.length === 4 ? `${user.username}#${user.discriminator}` : `@${user.username}` + } + } + + if (member?.avatar || user.avatar) { + // const avatarPath = file.userAvatar(user) // the user avatar only + const avatarPath = file.memberAvatar(guildID, user, member) // the member avatar or the user avatar + content["moe.cadence.ooye.member"].avatar = avatarPath + content.avatar_url = await file.uploadDiscordFileToMxc(avatarPath) + } + + return content +} + +/** + * https://gitdab.com/cadence/out-of-your-element/issues/9 + * @param {DiscordTypes.APIUser} user + * @param {Omit | undefined} member + * @param {DiscordTypes.APIGuild} guild + * @param {DiscordTypes.APIGuildChannel} channel + * @returns {number} 0 to 100 + */ +function memberToPowerLevel(user, member, guild, channel) { + if (!member) return 0 + + const permissions = utils.getPermissions(member.roles, guild.roles, user.id, channel.permission_overwrites) + const everyonePermissions = utils.getPermissions([], guild.roles, undefined, channel.permission_overwrites) + /* + * PL 100 = Administrator = People who can brick the room. RATIONALE: + * - Administrator. + * - Manage Webhooks: People who remove the webhook can break the room. + * - Manage Guild: People who can manage guild can add bots. + * - Manage Channels: People who can manage the channel can delete it. + * (Setting sim users to PL 100 is safe because even though we can't demote the sims we can use code to make the sims demote themselves.) + */ + if (guild.owner_id === user.id || utils.hasSomePermissions(permissions, ["Administrator", "ManageWebhooks", "ManageGuild", "ManageChannels"])) return 100 + /* + * PL 50 = Moderator = People who can manage people and messages in many ways. RATIONALE: + * - Manage Messages: Can moderate by pinning or deleting the conversation. + * - Manage Nicknames: Can moderate by removing inappropriate nicknames. + * - Manage Threads: Can moderate by deleting conversations. + * - Kick Members & Ban Members: Can moderate by removing disruptive people. + * - Mute Members & Deafen Members: Can moderate by silencing disruptive people in ways they can't undo. + * - Moderate Members. + */ + if (utils.hasSomePermissions(permissions, ["ManageMessages", "ManageNicknames", "ManageThreads", "KickMembers", "BanMembers", "MuteMembers", "DeafenMembers", "ModerateMembers"])) return 50 + /* PL 50 = if room is read-only but the user has been specially allowed to send messages */ + const everyoneCanSend = utils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages) + const userCanSend = utils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.SendMessages) + if (!everyoneCanSend && userCanSend) return createRoom.READ_ONLY_ROOM_EVENTS_DEFAULT_POWER + /* PL 20 = Mention Everyone for technical reasons. */ + const everyoneCanMentionEveryone = utils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.MentionEveryone) + const userCanMentionEveryone = utils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.MentionEveryone) + if (!everyoneCanMentionEveryone && userCanMentionEveryone) return 20 + return 0 +} + +/** + * @param {any} content + * @param {number} powerLevel + */ +function _hashProfileContent(content, powerLevel) { + const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}\u0000${powerLevel}`) + const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range + return signedHash +} + +/** + * Sync profile data for a sim user. This function follows the following process: + * 1. Join the sim to the room if needed + * 2. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before + * 3. Calculate the power level the user should get based on their Discord permissions + * 4. Compare against the previously known state content, which is helpfully stored in the database + * 5. If the state content or power level have changed, send them to Matrix and update them in the database for next time + * @param {DiscordTypes.APIUser} user + * @param {Omit | undefined} member + * @param {DiscordTypes.APIGuildChannel} channel + * @param {DiscordTypes.APIGuild} guild + * @param {string} roomID + * @returns {Promise} mxid of the updated sim + */ +async function syncUser(user, member, channel, guild, roomID) { + const mxid = await ensureSimJoined(user, roomID) + const content = await memberToStateContent(user, member, guild.id) + const powerLevel = memberToPowerLevel(user, member, guild, channel) + const currentHash = _hashProfileContent(content, powerLevel) + const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get() + // only do the actual sync if the hash has changed since we last looked + const hashHasChanged = existingHash !== currentHash + // however, do not overwrite pre-existing data if we already have data and `member` is not accessible, because this would replace good data with bad data + const wouldOverwritePreExisting = existingHash && !member + if (hashHasChanged && !wouldOverwritePreExisting) { + // Update room member state + await api.sendState(roomID, "m.room.member", mxid, content, mxid) + // Update power levels + await api.setUserPower(roomID, mxid, powerLevel) + // Update cached hash + db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid) + } + return mxid +} + +/** + * @param {string} roomID + */ +async function syncAllUsersInRoom(roomID) { + const mxids = select("sim_member", "mxid", {room_id: roomID}).pluck().all() + + const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get() + assert.ok(typeof channelID === "string") + + /** @ts-ignore @type {DiscordTypes.APIGuildChannel} */ + const channel = discord.channels.get(channelID) + const guildID = channel.guild_id + assert.ok(typeof guildID === "string") + /** @ts-ignore @type {DiscordTypes.APIGuild} */ + const guild = discord.guilds.get(guildID) + + for (const mxid of mxids) { + const userID = select("sim", "user_id", {mxid}).pluck().get() + assert.ok(typeof userID === "string") + + /** @ts-ignore @type {Required} */ + const member = await discord.snow.guild.getGuildMember(guildID, userID) + /** @ts-ignore @type {Required} user */ + const user = member.user + assert.ok(user) + + console.log(`[user sync] to matrix: ${user.username} in ${channel.name}`) + await syncUser(user, member, channel, guild, roomID) + } +} + +module.exports._memberToStateContent = memberToStateContent +module.exports._hashProfileContent = _hashProfileContent +module.exports.ensureSim = ensureSim +module.exports.ensureSimJoined = ensureSimJoined +module.exports.syncUser = syncUser +module.exports.syncAllUsersInRoom = syncAllUsersInRoom +module.exports._memberToPowerLevel = memberToPowerLevel diff --git a/src/d2m/actions/register-user.test.js b/src/d2m/actions/register-user.test.js new file mode 100644 index 0000000..13971b3 --- /dev/null +++ b/src/d2m/actions/register-user.test.js @@ -0,0 +1,126 @@ +const {_memberToStateContent, _memberToPowerLevel} = require("./register-user") +const {test} = require("supertape") +const data = require("../../../test/data") +const mixin = require("@cloudrac3r/mixin-deep") +const DiscordTypes = require("discord-api-types/v10") + +test("member2state: without member nick or avatar", async t => { + t.deepEqual( + await _memberToStateContent(data.member.kumaccino.user, data.member.kumaccino, data.guild.general.id), + { + avatar_url: "mxc://cadence.moe/UpAeIqeclhKfeiZNdIWNcXXL", + displayname: "kumaccino", + membership: "join", + "moe.cadence.ooye.member": { + avatar: "/avatars/113340068197859328/b48302623a12bc7c59a71328f72ccb39.png?size=1024" + }, + "uk.half-shot.discord.member": { + bot: false, + displayColor: 10206929, + id: "113340068197859328", + username: "@kumaccino" + } + } + ) +}) + +test("member2state: with global name, without member nick or avatar", async t => { + t.deepEqual( + await _memberToStateContent(data.member.papiophidian.user, data.member.papiophidian, data.guild.general.id), + { + avatar_url: "mxc://cadence.moe/JPzSmALLirnIprlSMKohSSoX", + displayname: "PapiOphidian", + membership: "join", + "moe.cadence.ooye.member": { + avatar: "/avatars/320067006521147393/5fc4ad85c1ea876709e9a7d3374a78a1.png?size=1024" + }, + "uk.half-shot.discord.member": { + bot: false, + displayColor: 1579292, + id: "320067006521147393", + username: "@papiophidian" + } + } + ) +}) + +test("member2state: with member nick and avatar", async t => { + t.deepEqual( + await _memberToStateContent(data.member.sheep.user, data.member.sheep, data.guild.general.id), + { + avatar_url: "mxc://cadence.moe/rfemHmAtcprjLEiPiEuzPhpl", + displayname: "The Expert's Submarine", + membership: "join", + "moe.cadence.ooye.member": { + avatar: "/guilds/112760669178241024/users/134826546694193153/avatars/38dd359aa12bcd52dd3164126c587f8c.png?size=1024" + }, + "uk.half-shot.discord.member": { + bot: false, + displayColor: null, + id: "134826546694193153", + username: "@aprilsong" + } + } + ) +}) + +test("member2power: default to zero if member roles unknown", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, null, data.guild.data_horde, data.channel.saving_the_world) + t.equal(power, 0) +}) + +test("member2power: unremarkable = 0", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: [] + }, data.guild.data_horde, data.channel.general) + t.equal(power, 0) +}) + +test("member2power: can mention everyone = 20", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: ["684524730274807911"] + }, data.guild.data_horde, data.channel.general) + t.equal(power, 20) +}) + +test("member2power: can send messages in protected channel due to role = 50", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: ["684524730274807911"] + }, data.guild.data_horde, data.channel.saving_the_world) + t.equal(power, 50) +}) + +test("member2power: can send messages in protected channel due to user override = 50", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: [] + }, data.guild.data_horde, mixin({}, data.channel.saving_the_world, { + permission_overwrites: data.channel.saving_the_world.permission_overwrites.concat({ + type: DiscordTypes.OverwriteType.member, + id: data.user.clyde_ai.id, + allow: String(DiscordTypes.PermissionFlagsBits.SendMessages), + deny: "0" + }) + })) + t.equal(power, 50) +}) + +test("member2power: can kick users = 50", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: ["682789592390281245"] + }, data.guild.data_horde, data.channel.general) + t.equal(power, 50) +}) + +test("member2power: can manage channels = 100", async t => { + const power = _memberToPowerLevel(data.user.clyde_ai, { + roles: ["665290147377578005"] + }, data.guild.data_horde, data.channel.saving_the_world) + t.equal(power, 100) +}) + +test("member2power: pathfinder use case", async t => { + const power = _memberToPowerLevel(data.user.jerassicore, { + roles: ["1235396773510647810", "1359752622130593802", "1249165855632265267", "1380768596929806356", "1380756348190462015"] + }, data.guild.pathfinder, data.channel.character_art) + t.equal(power, 50) +}) diff --git a/d2m/actions/remove-reaction.js b/src/d2m/actions/remove-reaction.js similarity index 82% rename from d2m/actions/remove-reaction.js rename to src/d2m/actions/remove-reaction.js index 95fc0aa..06c4b59 100644 --- a/d2m/actions/remove-reaction.js +++ b/src/d2m/actions/remove-reaction.js @@ -23,16 +23,7 @@ async function removeSomeReactions(data) { const eventIDForMessage = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get() if (!eventIDForMessage) return - /** @type {Ty.Event.Outer[]} */ - let reactions = [] - /** @type {string | undefined} */ - let nextBatch = undefined - do { - /** @type {Ty.Pagination>} */ - const res = await api.getRelations(roomID, eventIDForMessage, {from: nextBatch}, "m.annotation") - reactions = reactions.concat(res.chunk) - nextBatch = res.next_batch - } while (nextBatch) + const reactions = await api.getFullRelations(roomID, eventIDForMessage, "m.annotation") // Run the proper strategy and any strategy-specific database changes const removals = await @@ -52,7 +43,7 @@ async function removeSomeReactions(data) { * @param {Ty.Event.Outer[]} reactions */ async function removeReaction(data, reactions) { - const key = await emojiToKey.emojiToKey(data.emoji) + const key = await emojiToKey.emojiToKey(data.emoji, data.message_id) return converter.removeReaction(data, reactions, key) } @@ -61,8 +52,8 @@ async function removeReaction(data, reactions) { * @param {Ty.Event.Outer[]} reactions */ async function removeEmojiReaction(data, reactions) { - const key = await emojiToKey.emojiToKey(data.emoji) - const discordPreferredEncoding = emoji.encodeEmoji(key, undefined) + const key = await emojiToKey.emojiToKey(data.emoji, data.message_id) + const discordPreferredEncoding = await emoji.encodeEmoji(key, undefined) db.prepare("DELETE FROM reaction WHERE message_id = ? AND encoded_emoji = ?").run(data.message_id, discordPreferredEncoding) return converter.removeEmojiReaction(data, reactions, key) diff --git a/src/d2m/actions/retrigger.js b/src/d2m/actions/retrigger.js new file mode 100644 index 0000000..aa79a79 --- /dev/null +++ b/src/d2m/actions/retrigger.js @@ -0,0 +1,84 @@ +// @ts-check + +const {EventEmitter} = require("events") +const passthrough = require("../../passthrough") +const {select} = passthrough + +const DEBUG_RETRIGGER = false + +function debugRetrigger(message) { + if (DEBUG_RETRIGGER) { + console.log(message) + } +} + +const paused = new Set() +const emitter = new EventEmitter() + +/** + * Due to Eventual Consistency(TM) an update/delete may arrive before the original message arrives + * (or before the it has finished being bridged to an event). + * In this case, wait until the original message has finished bridging, then retrigger the passed function. + * @template {(...args: any[]) => Promise} T + * @param {string} messageID + * @param {T} fn + * @param {Parameters} rest + * @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered + */ +function eventNotFoundThenRetrigger(messageID, fn, ...rest) { + if (!paused.has(messageID)) { + const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get() + if (eventID) { + debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`) + return false // event was found so don't retrigger + } + } + + debugRetrigger(`[retrigger] WAIT mid = ${messageID}`) + emitter.once(messageID, () => { + debugRetrigger(`[retrigger] TRIGGER mid = ${messageID}`) + fn(...rest) + }) + // if the event never arrives, don't trigger the callback, just clean up + setTimeout(() => { + if (emitter.listeners(messageID).length) { + debugRetrigger(`[retrigger] EXPIRE mid = ${messageID}`) + } + emitter.removeAllListeners(messageID) + }, 60 * 1000) // 1 minute + return true // event was not found, then retrigger +} + +/** + * Anything calling retrigger during the callback will be paused and retriggered after the callback resolves. + * @template T + * @param {string} messageID + * @param {Promise} promise + * @returns {Promise} + */ +async function pauseChanges(messageID, promise) { + try { + debugRetrigger(`[retrigger] PAUSE mid = ${messageID}`) + paused.add(messageID) + return await promise + } finally { + debugRetrigger(`[retrigger] RESUME mid = ${messageID}`) + paused.delete(messageID) + messageFinishedBridging(messageID) + } +} + +/** + * Triggers any pending operations that were waiting on the corresponding event ID. + * @param {string} messageID + */ +function messageFinishedBridging(messageID) { + if (emitter.listeners(messageID).length) { + debugRetrigger(`[retrigger] EMIT mid = ${messageID}`) + } + emitter.emit(messageID) +} + +module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger +module.exports.messageFinishedBridging = messageFinishedBridging +module.exports.pauseChanges = pauseChanges diff --git a/d2m/actions/send-message.js b/src/d2m/actions/send-message.js similarity index 67% rename from d2m/actions/send-message.js rename to src/d2m/actions/send-message.js index b59fc7f..b1cb680 100644 --- a/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -1,6 +1,7 @@ // @ts-check -const assert = require("assert") +const assert = require("assert").strict +const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../../passthrough") const { discord, sync, db } = passthrough @@ -10,32 +11,41 @@ const messageToEvent = sync.require("../converters/message-to-event") const api = sync.require("../../matrix/api") /** @type {import("./register-user")} */ const registerUser = sync.require("./register-user") +/** @type {import("./register-pk-user")} */ +const registerPkUser = sync.require("./register-pk-user") /** @type {import("../actions/create-room")} */ const createRoom = sync.require("../actions/create-room") /** @type {import("../../discord/utils")} */ const dUtils = sync.require("../../discord/utils") /** - * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message - * @param {import("discord-api-types/v10").APIGuild} guild + * @param {DiscordTypes.GatewayMessageCreateDispatchData} message + * @param {DiscordTypes.APIGuildChannel} channel + * @param {DiscordTypes.APIGuild} guild + * @param {{speedbump_id: string, speedbump_webhook_id: string} | null} row data about the webhook which is proxying messages in this channel */ -async function sendMessage(message, guild) { +async function sendMessage(message, channel, guild, row) { const roomID = await createRoom.ensureRoom(message.channel_id) let senderMxid = null if (!dUtils.isWebhookMessage(message)) { - if (message.member) { // available on a gateway message create event - senderMxid = await registerUser.syncUser(message.author, message.member, message.guild_id, roomID) - } else { // well, good enough... - senderMxid = await registerUser.ensureSimJoined(message.author, roomID) + if (message.author.id === discord.application.id) { + // no need to sync the bot's own user + } else { + senderMxid = await registerUser.syncUser(message.author, message.member, channel, guild, roomID) + } + } else if (row && row.speedbump_webhook_id === message.webhook_id) { + // Handle the PluralKit public instance + if (row.speedbump_id === "466378653216014359") { + senderMxid = await registerPkUser.syncUser(message.id, message.author, roomID, true) } } - const events = await messageToEvent.messageToEvent(message, guild, {}, {api}) + const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow}) const eventIDs = [] if (events.length) { - db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) - if (senderMxid) api.sendTyping(roomID, false, senderMxid) + db.prepare("INSERT OR IGNORE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(message.id, message.channel_id) + if (senderMxid) api.sendTyping(roomID, false, senderMxid).catch(() => {}) } for (const event of events) { const part = event === events[0] ? 0 : 1 diff --git a/src/d2m/actions/set-presence.js b/src/d2m/actions/set-presence.js new file mode 100644 index 0000000..f26668f --- /dev/null +++ b/src/d2m/actions/set-presence.js @@ -0,0 +1,114 @@ +// @ts-check + +const passthrough = require("../../passthrough") +const {sync, select} = passthrough +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") + +/* + We do this in two phases for optimisation reasons. + Discord sends us an event when the presence *changes.* + We need to keep the event data in memory because we need to *repeatedly* send it to Matrix using a long-lived loop. + + There are two phases to get it from Discord to Matrix. + The first phase stores Discord presence data in memory. + The second phase loops over the memory and sends it on to Matrix. + + Optimisations: + * Presence can be deactivated per-guild in OOYE settings. If the user doesn't share any presence-enabled-guilds with us, we don't need to do anything. + * Presence can be sent for users without sims. In this case, they will be discarded from memory when the next loop begins. + * Matrix ID is cached in memory on the Presence class. The alternative to this is querying it every time we receive a presence change event in a valid guild. + * Presence can be sent multiple times in a row for the same user for each guild we share. The loop timer prevents these "changes" from individually reaching the homeserver. +*/ + +// Synapse expires each user's presence after 30 seconds and makes them offline, so we have to loop every 28 seconds and update each user again. +const presenceLoopInterval = 28e3 + +// Cache the list of enabled guilds rather than accessing it like multiple times per second when any user changes presence +const guildPresenceSetting = new class { + /** @private @type {Set} */ guilds + constructor() { + this.update() + } + update() { + this.guilds = new Set(select("guild_space", "guild_id", {presence: 1}).pluck().all()) + } + isEnabled(guildID) { + return this.guilds.has(guildID) + } +} + +class Presence extends sync.reloadClassMethods(() => Presence) { + /** @type {string} */ userID + /** @type {{presence: "online" | "offline" | "unavailable", status_msg?: string}} */ data + /** @private @type {?string | undefined} */ mxid + /** @private @type {number} */ delay = Math.random() + + constructor(userID) { + super() + this.userID = userID + } + + /** + * @param {string} status status field from Discord's PRESENCE_UPDATE event + */ + setData(status) { + const presence = + ( status === "online" ? "online" + : status === "offline" ? "offline" + : "unavailable") + this.data = {presence} + } + + sync(presences) { + const mxid = this.mxid ??= select("sim", "mxid", {user_id: this.userID}).pluck().get() + if (!mxid) return presences.delete(this.userID) + // I haven't tried, but I assume Synapse explodes if you try to update too many presences at the same time. + // This random delay will space them out over the whole 28 second cycle. + setTimeout(() => { + api.setPresence(this.data, mxid).catch(() => {}) + }, this.delay * presenceLoopInterval).unref() + } +} + +const presenceTracker = new class { + /** @private @type {Map} userID -> Presence */ presences = sync.remember(() => new Map()) + + constructor() { + sync.addTemporaryInterval(() => this.syncPresences(), presenceLoopInterval) + } + + /** + * This function is called for each Discord presence packet. + * @param {string} userID Discord user ID + * @param {string} guildID Discord guild ID that this presence applies to (really, the same presence applies to every single guild, but is delivered separately by Discord for some reason) + * @param {string} status status field from Discord's PRESENCE_UPDATE event + */ + incomingPresence(userID, guildID, status) { + // stop tracking offline presence objects - they will naturally expire and fall offline on the homeserver + if (status === "offline") return this.presences.delete(userID) + // check if we care about this guild + if (!guildPresenceSetting.isEnabled(guildID)) return + // start tracking presence for user (we'll check if they have a sim in the next sync loop) + this.getOrCreatePresence(userID).setData(status) + } + + /** @private */ + getOrCreatePresence(userID) { + return this.presences.get(userID) || (() => { + const presence = new Presence(userID) + this.presences.set(userID, presence) + return presence + })() + } + + /** @private */ + syncPresences() { + for (const presence of this.presences.values()) { + presence.sync(this.presences) + } + } +} + +module.exports.presenceTracker = presenceTracker +module.exports.guildPresenceSetting = guildPresenceSetting diff --git a/src/d2m/actions/speedbump.js b/src/d2m/actions/speedbump.js new file mode 100644 index 0000000..7c3109b --- /dev/null +++ b/src/d2m/actions/speedbump.js @@ -0,0 +1,68 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const passthrough = require("../../passthrough") +const {discord, select, db} = passthrough + +const SPEEDBUMP_SPEED = 4000 // 4 seconds delay +const SPEEDBUMP_UPDATE_FREQUENCY = 2 * 60 * 60 // 2 hours + +/** @type {Set} */ +const KNOWN_BOTS = new Set([ + "466378653216014359" // PluralKit +]) + +/** + * Fetch new speedbump data for the channel and put it in the database as cache + * @param {string} channelID + * @param {number?} lastChecked + */ +async function updateCache(channelID, lastChecked) { + const now = Math.floor(Date.now() / 1000) + if (lastChecked && now - lastChecked < SPEEDBUMP_UPDATE_FREQUENCY) return + const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID) + const found = webhooks.find(b => KNOWN_BOTS.has(b.application_id)) + const foundApplication = found?.application_id + const foundWebhook = found?.id + db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_webhook_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(foundApplication, foundWebhook, now, channelID) +} + +/** @type {Set} set of messageID */ +const bumping = new Set() + +/** + * Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted. + * @param {string} messageID + * @returns whether it was deleted + */ +async function doSpeedbump(messageID) { + bumping.add(messageID) + await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED)) + return !bumping.delete(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 + * @returns whether it was deleted, and data about the channel's (not thread's) 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 +} + +/** + * @param {string} messageID + */ +function onMessageDelete(messageID) { + bumping.delete(messageID) +} + +module.exports.updateCache = updateCache +module.exports.doSpeedbump = doSpeedbump +module.exports.maybeDoSpeedbump = maybeDoSpeedbump +module.exports.onMessageDelete = onMessageDelete diff --git a/src/d2m/actions/update-pins.js b/src/d2m/actions/update-pins.js new file mode 100644 index 0000000..15febaa --- /dev/null +++ b/src/d2m/actions/update-pins.js @@ -0,0 +1,47 @@ +// @ts-check + +const passthrough = require("../../passthrough") +const {discord, sync, db} = passthrough +/** @type {import("../converters/pins-to-list")} */ +const pinsToList = sync.require("../converters/pins-to-list") +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("../../matrix/kstate")} */ +const ks = sync.require("../../matrix/kstate") + +/** + * @template {string | null | undefined} T + * @param {T} timestamp + * @returns {T extends string ? number : null} + */ +function convertTimestamp(timestamp) { + // @ts-ignore + return typeof timestamp === "string" ? Math.floor(new Date(timestamp).getTime() / 1000) : null +} + +/** + * @param {string} channelID + * @param {string} roomID + * @param {number?} convertedTimestamp + */ +async function updatePins(channelID, roomID, convertedTimestamp) { + try { + var discordPins = await discord.snow.channel.getChannelPinnedMessages(channelID) + } catch (e) { + if (e.message === `{"message": "Missing Access", "code": 50001}`) { + return // Discord sends channel pins update events even for channels that the bot can't view/get pins in, just ignore it + } + throw e + } + + const kstate = await ks.roomToKState(roomID) + const pinned = pinsToList.pinsToList(discordPins, kstate) + + const diff = ks.diffKState(kstate, {"m.room.pinned_events/": {pinned}}) + await ks.applyKStateDiffToRoom(roomID, diff) + + db.prepare("UPDATE channel_room SET last_bridged_pin_timestamp = ? WHERE channel_id = ?").run(convertedTimestamp || 0, channelID) +} + +module.exports.convertTimestamp = convertTimestamp +module.exports.updatePins = updatePins diff --git a/src/d2m/converters/edit-to-changes.js b/src/d2m/converters/edit-to-changes.js new file mode 100644 index 0000000..c615a3f --- /dev/null +++ b/src/d2m/converters/edit-to-changes.js @@ -0,0 +1,213 @@ +// @ts-check + +const assert = require("assert").strict + +const passthrough = require("../../passthrough") +const {sync, select, from} = passthrough +/** @type {import("./message-to-event")} */ +const messageToEvent = sync.require("../converters/message-to-event") +/** @type {import("../../m2d/converters/utils")} */ +const utils = sync.require("../../m2d/converters/utils") + +function eventCanBeEdited(ev) { + // Discord does not allow files, images, attachments, or videos to be edited. + if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") { + return false + } + // Discord does not allow stickers to be edited. + if (ev.old.event_type === "m.sticker") { + return false + } + // Anything else is fair game. + return true +} + +function eventIsText(ev) { + return ev.old.event_type === "m.room.message" && (ev.old.event_subtype === "m.text" || ev.old.event_subtype === "m.notice") +} + +/** + * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message + * @param {import("discord-api-types/v10").APIGuild} guild + * @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API + */ +async function editToChanges(message, guild, api) { + // If it is a user edit, allow deleting old messages (e.g. they might have removed text from an image). + // If it is the system adding a generated embed to a message, don't delete old messages since the system only sends partial data. + // Since an update in August 2024, the system always provides the full data of message updates. I'll leave in the old code since it won't cause problems. + + const isGeneratedEmbed = !("content" in message) + + // Figure out what events we will be replacing + + const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() + assert(roomID) + const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all() + + /** @type {string?} Null if we don't have a sender in the room, which will happen if it's a webhook's message. The bridge bot will do the edit instead. */ + let senderMxid = null + if (message.author) { + senderMxid = from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get() || null + } else { + // Should be a system generated embed. We want the embed to be sent by the same user who sent the message, so that the messages get grouped in most clients. + const eventID = oldEventRows[0].event_id // a calling function should have already checked that there is at least one message to edit + const event = await api.getEvent(roomID, eventID) + if (utils.eventSenderIsFromDiscord(event.sender)) { + senderMxid = event.sender + } + } + + // Figure out what we will be replacing them with + + const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api}) + const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api}) + assert.ok(newFallbackContent.length === newInnerContent.length) + + // Match the new events to the old events + + /* + Rules: + + The events must have the same type. + + The events must have the same subtype. + Events will therefore be divided into four categories: + */ + /** 1. Events that are matched, and should be edited by sending another m.replace event */ + let eventsToReplace = [] + /** 2. Events that are present in the old version only, and should be blanked or redacted */ + let eventsToRedact = [] + /** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */ + let eventsToSend = [] + /** 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. */ + let unchangedEvents = [] + + function shift() { + newFallbackContent.shift() + newInnerContent.shift() + } + + // For each old event... + outer: while (newFallbackContent.length) { + const newe = newFallbackContent[0] + // Find a new event to pair it with... + for (let i = 0; i < oldEventRows.length; i++) { + const olde = oldEventRows[i] + if (olde.event_type === newe.$type && olde.event_subtype === (newe.msgtype || null)) { // The spec does allow subtypes to change, so I can change this condition later if I want to + // Found one! + // Set up the pairing + eventsToReplace.push({ + old: olde, + newFallbackContent: newFallbackContent[0], + newInnerContent: newInnerContent[0] + }) + // These events have been handled now, so remove them from the source arrays + shift() + oldEventRows.splice(i, 1) + // Go all the way back to the start of the next iteration of the outer loop + continue outer + } + } + // If we got this far, we could not pair it to an existing event, so it'll have to be a new one + eventsToSend.push(newInnerContent[0]) + shift() + } + // Anything remaining in oldEventRows is present in the old version only and should be redacted. + eventsToRedact = oldEventRows.map(e => ({old: e})) + + // If this is a generated embed update, only allow the embeds to be updated, since the system only sends data about events. Ignore changes to other things. + if (isGeneratedEmbed) { + unchangedEvents.push(...eventsToRedact.filter(e => e.old.event_subtype !== "m.notice")) // Move them from eventsToRedact to unchangedEvents. + eventsToRedact = eventsToRedact.filter(e => e.old.event_subtype === "m.notice") + } + + // Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed! + // (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.) + // So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed. + unchangedEvents.push(...eventsToReplace.filter(ev => !eventCanBeEdited(ev))) // Move them from eventsToRedact to unchangedEvents. + eventsToReplace = eventsToReplace.filter(eventCanBeEdited) + + // Now, everything in eventsToReplace has the potential to have changed, but did it actually? + // (Example: if a URL preview was generated or updated, the message text won't have changed.) + // Only way to detect this is by text content. So we'll remove text events from eventsToReplace that have the same new text as text currently in the event. + for (let i = eventsToReplace.length; i--;) { // move backwards through array + const event = eventsToReplace[i] + if (!eventIsText(event)) continue // not text, can't analyse + const oldEvent = await api.getEvent(roomID, eventsToReplace[i].old.event_id) + const oldEventBodyWithoutQuotedReply = oldEvent.content.body?.replace(/^(>.*\n)*\n*/sm, "") + if (oldEventBodyWithoutQuotedReply !== event.newInnerContent.body) continue // event changed, must replace it + // Move it from eventsToRedact to unchangedEvents. + unchangedEvents.push(...eventsToReplace.filter(ev => ev.old.event_id === event.old.event_id)) + eventsToReplace = eventsToReplace.filter(ev => ev.old.event_id !== event.old.event_id) + } + + // We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times. + // This would be disrupted if existing events that are (reaction_)part = 0 will be redacted. + // If that is the case, pick a different existing or newly sent event to be (reaction_)part = 0. + /** @type {({column: string, eventID: string, value?: number} | {column: string, nextEvent: true})[]} */ + const promotions = [] + for (const column of ["part", "reaction_part"]) { + const candidatesForParts = unchangedEvents.concat(eventsToReplace) + // If no events with part = 0 exist (or will exist), we need to do some management. + if (!candidatesForParts.some(e => e.old[column] === 0)) { + // Try to find an existing event to promote. Bigger order is better. + if (candidatesForParts.length) { + const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.old.event_subtype === "m.text") + candidatesForParts.sort((a, b) => order(b) - order(a)) + if (column === "part") { + promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one + } else if (eventsToSend.length) { + promotions.push({column, nextEvent: true}) // reaction_part should be the last one + } else { + promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one + } + } + // Or, if there are no existing events to promote and new events will be sent, whatever gets sent will be the next part = 0. + else { + promotions.push({column, nextEvent: true}) + } + } + } + + // If adding events, try to keep reactions attached to the bottom of the group (unless reactions have already been added) + if (eventsToSend.length && !promotions.length) { + const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get() + if (!existingReaction) { + const existingPartZero = unchangedEvents.concat(eventsToReplace).find(p => p.old.reaction_part === 0) + assert(existingPartZero) // will exist because a reaction_part=0 always exists and no events are being removed + promotions.push({column: "reaction_part", eventID: existingPartZero.old.event_id, value: 1}) // update the current reaction_part to 1 + promotions.push({column: "reaction_part", nextEvent: true}) // the newly created event will have reaction_part = 0 + } + } + + // Removing unnecessary properties before returning + eventsToRedact = eventsToRedact.map(e => e.old.event_id) + eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)})) + + return {roomID, eventsToReplace, eventsToRedact, eventsToSend, senderMxid, promotions} +} + +/** + * @template T + * @param {string} oldID + * @param {T} newFallbackContent + * @param {T} newInnerContent + * @returns {import("../../types").Event.ReplacementContent} content + */ +function makeReplacementEventContent(oldID, newFallbackContent, newInnerContent) { + const content = { + ...newFallbackContent, + "m.mentions": {}, + "m.new_content": { + ...newInnerContent + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: oldID + } + } + delete content["m.new_content"]["$type"] + // Client-Server API spec 11.37.3: Any m.relates_to property within m.new_content is ignored. + delete content["m.new_content"]["m.relates_to"] + return content +} + +module.exports.editToChanges = editToChanges diff --git a/src/d2m/converters/edit-to-changes.test.js b/src/d2m/converters/edit-to-changes.test.js new file mode 100644 index 0000000..30549c7 --- /dev/null +++ b/src/d2m/converters/edit-to-changes.test.js @@ -0,0 +1,362 @@ +const {test} = require("supertape") +const {editToChanges} = require("./edit-to-changes") +const data = require("../../../test/data") +const Ty = require("../../types") + +test("edit2changes: edit by webhook", async t => { + let called = 0 + const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edit_by_webhook, data.guild.general, { + getEvent(roomID, eventID) { + called++ + t.equal(eventID, "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10") + return {content: {body: "dummy"}} + } + }) + t.deepEqual(eventsToRedact, []) + t.deepEqual(eventsToSend, []) + t.deepEqual(eventsToReplace, [{ + oldID: "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10", + newContent: { + $type: "m.room.message", + msgtype: "m.text", + body: "* test 2", + "m.mentions": {}, + "m.new_content": { + // *** Replaced With: *** + msgtype: "m.text", + body: "test 2", + "m.mentions": {} + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: "$zXSlyI78DQqQwwfPUSzZ1b-nXzbUrCDljJgnGDdoI10" + } + } + }]) + t.equal(senderMxid, null) + t.deepEqual(promotions, []) + t.equal(called, 1) +}) + +test("edit2changes: bot response", async t => { + const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.bot_response, data.guild.general, { + getEvent(roomID, eventID) { + t.equal(eventID, "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY") + return {content: {body: "dummy"}} + }, + async getJoinedMembers(roomID) { + t.equal(roomID, "!hYnGGlPHlbujVVfktC:cadence.moe") + return new Promise(resolve => { + setTimeout(() => { + resolve({ + joined: { + "@cadence:cadence.moe": { + displayname: "cadence [they]", + avatar_url: "whatever" + }, + "@_ooye_botrac4r:cadence.moe": { + displayname: "botrac4r", + avatar_url: "whatever" + } + } + }) + }) + }) + } + }) + t.deepEqual(eventsToRedact, []) + t.deepEqual(eventsToSend, []) + t.deepEqual(eventsToReplace, [{ + oldID: "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY", + newContent: { + $type: "m.room.message", + msgtype: "m.text", + body: "* :ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", + format: "org.matrix.custom.html", + formatted_body: '* :ae_botrac4r: @cadence asked ­, I respond: Stop drinking paint. (No)

Hit :bn_re: to reroll.', + "m.mentions": { + // Client-Server API spec 11.37.7: Copy Discord's behaviour by not re-notifying anyone that an *edit occurred* + }, + // *** Replaced With: *** + "m.new_content": { + msgtype: "m.text", + body: ":ae_botrac4r: @cadence asked ``­``, I respond: Stop drinking paint. (No)\n\nHit :bn_re: to reroll.", + format: "org.matrix.custom.html", + formatted_body: ':ae_botrac4r: @cadence asked ­, I respond: Stop drinking paint. (No)

Hit :bn_re: to reroll.', + "m.mentions": { + // Client-Server API spec 11.37.7: This should contain the mentions for the final version of the event + "user_ids": ["@cadence:cadence.moe"] + } + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: "$fdD9OZ55xg3EAsfvLZza5tMhtjUO91Wg3Otuo96TplY" + } + } + }]) + t.equal(senderMxid, "@_ooye_bojack_horseman:cadence.moe") + t.deepEqual(promotions, []) +}) + +test("edit2changes: remove caption from image", async t => { + const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.removed_caption_from_image, data.guild.general, {}) + t.deepEqual(eventsToRedact, ["$mtR8cJqM4fKno1bVsm8F4wUVqSntt2sq6jav1lyavuA"]) + t.deepEqual(eventsToSend, []) + t.deepEqual(eventsToReplace, []) + t.deepEqual(promotions, [{column: "part", eventID: "$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCI"}]) +}) + +test("edit2changes: change file type", async t => { + const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.changed_file_type, data.guild.general, {}) + t.deepEqual(eventsToRedact, ["$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ"]) + t.deepEqual(eventsToSend, [{ + $type: "m.room.message", + body: "📝 Uploaded file: https://bridge.example.org/download/discordcdn/112760669178241024/1141501302497615912/gaze_into_my_dark_mind.txt (20 MB)", + format: "org.matrix.custom.html", + formatted_body: "📝 Uploaded file: gaze_into_my_dark_mind.txt (20 MB)", + "m.mentions": {}, + msgtype: "m.text" + }]) + t.deepEqual(eventsToReplace, []) + t.deepEqual(promotions, [{column: "part", nextEvent: true}, {column: "reaction_part", nextEvent: true}]) +}) + +test("edit2changes: add caption back to that image (due to it having a reaction, the reaction_part will not be moved)", async t => { + const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.added_caption_to_image, data.guild.general, {}) + t.deepEqual(eventsToRedact, []) + t.deepEqual(eventsToSend, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "some text", + "m.mentions": {} + }]) + t.deepEqual(eventsToReplace, []) + t.deepEqual(promotions, []) +}) + +test("edit2changes: stickers and attachments are not changed, only the content can be edited", async t => { + let called = 0 + const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments, data.guild.general, { + getEvent(roomID, eventID) { + called++ + t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4") + return {content: {body: "dummy"}} + } + }) + t.deepEqual(eventsToRedact, []) + t.deepEqual(eventsToSend, []) + t.deepEqual(eventsToReplace, [{ + oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4", + newContent: { + $type: "m.room.message", + msgtype: "m.text", + body: "* only the content can be edited", + "m.mentions": {}, + // *** Replaced With: *** + "m.new_content": { + msgtype: "m.text", + body: "only the content can be edited", + "m.mentions": {} + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qdFv4" + } + } + }]) + t.equal(called, 1) +}) + +test("edit2changes: edit of reply to skull webp attachment with content", async t => { + const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edit_of_reply_to_skull_webp_attachment_with_content, data.guild.general, { + getEvent(roomID, eventID) { + t.equal(eventID, "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M") + return {content: {body: "dummy"}} + } + }) + t.deepEqual(eventsToRedact, []) + t.deepEqual(eventsToSend, []) + t.deepEqual(eventsToReplace, [{ + oldID: "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M", + newContent: { + $type: "m.room.message", + msgtype: "m.text", + body: "> Extremity: Image\n\n* Edit", + format: "org.matrix.custom.html", + formatted_body: + '
In reply to Extremity' + + '
Image
' + + '* Edit', + "m.mentions": {}, + "m.new_content": { + msgtype: "m.text", + body: "Edit", + "m.mentions": {} + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: "$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M" + } + } + }]) +}) + +test("edit2changes: edits the text event when multiple rows have part = 0 (should never happen in real life, but make sure the safety net works)", async t => { + const {eventsToRedact, eventsToReplace, eventsToSend} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_0, data.guild.general, { + getEvent(roomID, eventID) { + t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999") + return {content: {body: "dummy"}} + } + }) + t.deepEqual(eventsToRedact, []) + t.deepEqual(eventsToSend, []) + t.deepEqual(eventsToReplace, [{ + oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999", + newContent: { + $type: "m.room.message", + msgtype: "m.text", + body: "* only the content can be edited", + "m.mentions": {}, + // *** Replaced With: *** + "m.new_content": { + msgtype: "m.text", + body: "only the content can be edited", + "m.mentions": {} + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd999" + } + } + }]) +}) + +test("edit2changes: promotes the text event when multiple rows have part = 1 (should never happen in real life, but make sure the safety net works)", async t => { + const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.edited_content_with_sticker_and_attachments_but_all_parts_equal_1, data.guild.general, { + getEvent(roomID, eventID) { + t.equal(eventID, "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111") + return {content: {body: "dummy"}} + } + }) + t.deepEqual(eventsToRedact, []) + t.deepEqual(eventsToSend, []) + t.deepEqual(eventsToReplace, [{ + oldID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111", + newContent: { + $type: "m.room.message", + msgtype: "m.text", + body: "* only the content can be edited", + "m.mentions": {}, + // *** Replaced With: *** + "m.new_content": { + msgtype: "m.text", + body: "only the content can be edited", + "m.mentions": {} + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111" + } + } + }]) + t.deepEqual(promotions, [ + { + column: "part", + eventID: "$lnAF9IosAECTnlv9p2e18FG8rHn-JgYKHEHIh5qd111" + }, + { + column: "reaction_part", + eventID: "$f9cjKiacXI9qPF_nUAckzbiKnJEi0LM399kOkhdd111" + } + ]) +}) + +test("edit2changes: generated embed", async t => { + let called = 0 + const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_social_media_image, data.guild.general, { + async getEvent(roomID, eventID) { + called++ + t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") + t.equal(eventID, "$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0") + return {sender: "@_ooye_cadence:cadence.moe"} + } + }) + t.deepEqual(eventsToRedact, []) + t.deepEqual(eventsToReplace, []) + t.deepEqual(eventsToSend, [{ + $type: "m.room.message", + msgtype: "m.notice", + body: "| via hthrflwrs on cohost" + + "\n| \n| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty" + + "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:" + + "\n| \n| * Both players draw eight cards" + + "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand" + + "\n| * Both players present their best five-or-less-card pok...", + format: "org.matrix.custom.html", + formatted_body: `

hthrflwrs on cohost` + + `

This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO` + + `

1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:` + + `

  • Both players draw eight cards` + + `
  • Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand` + + `
  • Both players present their best five-or-less-card pok...

`, + "m.mentions": {} + }]) + t.deepEqual(promotions, [{ + "column": "reaction_part", + "eventID": "$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0", + "value": 1, + }, { + "column": "reaction_part", + "nextEvent": true, + }]) + t.equal(senderMxid, "@_ooye_cadence:cadence.moe") + t.equal(called, 1) +}) + +test("edit2changes: generated embed on a reply", async t => { + let called = 0 + const {senderMxid, eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_on_reply, data.guild.general, { + getEvent(roomID, eventID) { + called++ + t.equal(eventID, "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF") + return { + type: "m.room.message", + content: { + // Unfortunately the edited message doesn't include the message_reference field. Fine. Whatever. It looks normal if you're using a good client. + body: "> a Discord user: [Replied-to message content wasn't provided by Discord]" + + "\n\nhttps://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", + format: "org.matrix.custom.html", + formatted_body: "
In reply to a Discord user
[Replied-to message content wasn't provided by Discord]
https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe/$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM", + "m.mentions": {}, + "m.relates_to": { + event_id: "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF", + rel_type: "m.replace", + }, + msgtype: "m.text", + } + } + } + }) + t.deepEqual(eventsToRedact, []) + t.deepEqual(eventsToReplace, []) + t.deepEqual(eventsToSend, [{ + $type: "m.room.message", + msgtype: "m.notice", + body: "| ## Matrix - Decentralised and secure communication https://matrix.to/" + + "\n| \n| You're invited to talk on Matrix. If you don't already have a client this link will help you pick one, and join the conversation. If you already have one, this link will help you join the conversation", + format: "org.matrix.custom.html", + formatted_body: `

Matrix - Decentralised and secure communication` + + `

You're invited to talk on Matrix. If you don't already have a client this link will help you pick one, and join the conversation. If you already have one, this link will help you join the conversation

`, + "m.mentions": {} + }]) + t.deepEqual(promotions, [{ + "column": "reaction_part", + "eventID": "$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF", + "value": 1, + }, { + "column": "reaction_part", + "nextEvent": true, + }]) + t.equal(senderMxid, "@_ooye_cadence:cadence.moe") + t.equal(called, 1) +}) diff --git a/d2m/converters/emoji-to-key.js b/src/d2m/converters/emoji-to-key.js similarity index 71% rename from d2m/converters/emoji-to-key.js rename to src/d2m/converters/emoji-to-key.js index 267664c..54bda18 100644 --- a/d2m/converters/emoji-to-key.js +++ b/src/d2m/converters/emoji-to-key.js @@ -8,9 +8,10 @@ const file = sync.require("../../matrix/file") /** * @param {import("discord-api-types/v10").APIEmoji} emoji + * @param {string} message_id * @returns {Promise} */ -async function emojiToKey(emoji) { +async function emojiToKey(emoji, message_id) { let key if (emoji.id) { // Custom emoji @@ -30,7 +31,10 @@ async function emojiToKey(emoji) { // Default emoji const name = emoji.name assert(name) - key = name + // If the reaction was used on Matrix already, it might be using a different arrangement of Variation Selector 16 characters. + // We'll use the same arrangement that was originally used, otherwise a duplicate of the emoji will appear as a separate reaction. + const originalEncoding = select("reaction", "original_encoding", {message_id, encoded_emoji: encodeURIComponent(name)}).pluck().get() + key = originalEncoding || name } return key } diff --git a/d2m/converters/emoji-to-key.test.js b/src/d2m/converters/emoji-to-key.test.js similarity index 94% rename from d2m/converters/emoji-to-key.test.js rename to src/d2m/converters/emoji-to-key.test.js index 5af046c..544eada 100644 --- a/d2m/converters/emoji-to-key.test.js +++ b/src/d2m/converters/emoji-to-key.test.js @@ -2,7 +2,7 @@ const {test} = require("supertape") const {emojiToKey} = require("./emoji-to-key") -const data = require("../../test/data") +const data = require("../../../test/data") const Ty = require("../../types") test("emoji2key: unicode emoji works", async t => { diff --git a/src/d2m/converters/lottie.js b/src/d2m/converters/lottie.js new file mode 100644 index 0000000..969d345 --- /dev/null +++ b/src/d2m/converters/lottie.js @@ -0,0 +1,49 @@ +// @ts-check + +const assert = require("assert") +const stream = require("stream") +const {PNG} = require("@cloudrac3r/pngjs") + +const SIZE = 160 // Discord's display size on 1x displays is 160 + +/** + * @typedef RlottieWasm + * @prop {(string) => boolean} load load lottie data from string of json + * @prop {() => number} frames get number of frames + * @prop {(frameCount: number, width: number, height: number) => Uint8Array} render render lottie data to bitmap + */ + +const Rlottie = (async () => { + const Rlottie = require("./rlottie-wasm.js") + await new Promise(resolve => Rlottie.onRuntimeInitialized = resolve) + return Rlottie +})() + +/** + * @param {string} text + * @returns {Promise} + */ +async function convert(text) { + const r = await Rlottie + /** @type RlottieWasm */ + const rh = new r.RlottieWasm() + const status = rh.load(text) + assert(status, `Rlottie unable to load ${text.length} byte data file.`) + const rendered = rh.render(0, SIZE, SIZE) + let png = new PNG({ + width: SIZE, + height: SIZE, + bitDepth: 8, // 8 red + 8 green + 8 blue + 8 alpha + colorType: 6, // RGBA + inputColorType: 6, // RGBA + inputHasAlpha: true, + }) + png.data = Buffer.from(rendered) + // png.pack() is a bad stream and will throw away any data it sends if it's not connected to a destination straight away. + // We use Duplex.from to convert it into a good stream. + // @ts-ignore + return stream.Duplex.from(png.pack()) +} + +module.exports.convert = convert +module.exports.SIZE = SIZE diff --git a/src/d2m/converters/lottie.test.js b/src/d2m/converters/lottie.test.js new file mode 100644 index 0000000..9d9255b --- /dev/null +++ b/src/d2m/converters/lottie.test.js @@ -0,0 +1,34 @@ +// @ts-check + +const fs = require("fs") +const stream = require("stream") +const {test} = require("supertape") +const {convert} = require("./lottie") + +const WRITE_PNG = false + +test("lottie: can convert and save PNG", async t => { + const input = await fs.promises.readFile("test/res/lottie-bee.json", "utf8") + const resultStream = await convert(input) + /* c8 ignore next 3 */ + if (WRITE_PNG) { + resultStream.pipe(fs.createWriteStream("test/res/lottie-bee.png")) + t.fail("PNG written to /test/res/lottie-bee.png, please manually check it") + } else { + const expected = await fs.promises.readFile("test/res/lottie-bee.png") + const actual = Buffer.alloc(expected.length) + let i = 0 + await stream.promises.pipeline( + resultStream, + async function* (source) { + for await (const chunk of source) { + chunk.copy(actual, i) + i += chunk.length + } + }, + new stream.PassThrough() + ) + t.equal(i, actual.length, `allocated ${actual.length} bytes, but wrote ${i}`) + t.deepEqual(actual, expected) + } +}) diff --git a/src/d2m/converters/message-to-event.embeds.test.js b/src/d2m/converters/message-to-event.embeds.test.js new file mode 100644 index 0000000..ed165c6 --- /dev/null +++ b/src/d2m/converters/message-to-event.embeds.test.js @@ -0,0 +1,388 @@ +const {test} = require("supertape") +const {messageToEvent} = require("./message-to-event") +const data = require("../../../test/data") +const {db} = require("../../passthrough") + +test("message2event embeds: nothing but a field", async t => { + const events = await messageToEvent(data.message_with_embeds.nothing_but_a_field, data.guild.general, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + body: "> ↪️ @papiophidian: used `/stats`", + format: "org.matrix.custom.html", + formatted_body: "
↪️ @papiophidian used /stats
", + "m.mentions": {}, + msgtype: "m.text", + }, { + $type: "m.room.message", + "m.mentions": {}, + msgtype: "m.notice", + body: "| ### Amanda 🎵#2192 :online:" + + "\n| willow tree, branch 0" + + "\n| **❯ Uptime:**\n| 3m 55s\n| **❯ Memory:**\n| 64.45MB", + format: "org.matrix.custom.html", + formatted_body: '

Amanda 🎵#2192 \":online:\"' + + '
willow tree, branch 0
' + + '
❯ Uptime:
3m 55s' + + '
❯ Memory:
64.45MB

' + }]) +}) + +test("message2event embeds: reply with just an embed", async t => { + const events = await messageToEvent(data.message_with_embeds.reply_with_only_embed, data.guild.general, {}) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.notice", + "m.mentions": {}, + body: "> In reply to an unbridged message:" + + "\n> PokemonGod: https://twitter.com/dynastic/status/1707484191963648161" + + "\n\n| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145" + + "\n| \n| does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?" + + "\n| \n| ### Retweets" + + "\n| 119" + + "\n| \n| ### Likes" + + "\n| 5581" + + "\n| — Twitter", + format: "org.matrix.custom.html", + formatted_body: '
In reply to an unbridged message from PokemonGod:
https://twitter.com/dynastic/status/1707484191963648161
' + + '

⏺️ dynastic (@dynastic)' + + '

does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?' + + '

Retweets
119

Likes
5581

— Twitter
' + }]) +}) + +test("message2event embeds: image embed and attachment", async t => { + const events = await messageToEvent(data.message_with_embeds.image_embed_and_attachment, data.guild.general, {}, { + api: { + async getJoinedMembers(roomID) { + return {joined: []} + } + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "https://tootsuite.net/Warp-Gate2.gif\ntanget: @ monster spawner", + format: "org.matrix.custom.html", + formatted_body: 'https://tootsuite.net/Warp-Gate2.gif
tanget: @ monster spawner', + "m.mentions": {} + }, { + $type: "m.room.message", + msgtype: "m.image", + url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR", + body: "Screenshot_20231001_034036.jpg", + external_url: "https://bridge.example.org/download/discordcdn/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg", + filename: "Screenshot_20231001_034036.jpg", + info: { + h: 1170, + w: 1080, + size: 51981, + mimetype: "image/jpeg" + }, + "m.mentions": {} + }]) +}) + +test("message2event embeds: blockquote in embed", async t => { + let called = 0 + const events = await messageToEvent(data.message_with_embeds.blockquote_in_embed, data.guild.general, {}, { + api: { + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@_ooye_bot:cadence.moe": 100 + } + } + }, + async getJoinedMembers(roomID) { + called++ + t.equal(roomID, "!qzDBLKlildpzrrOnFZ:cadence.moe") + return { + joined: { + "@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null}, + "@user:example.invalid": {display_name: null, avatar_url: null} + } + } + } + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: ":emoji: **4 |** #wonderland", + format: "org.matrix.custom.html", + formatted_body: `\":emoji:\" 4 | #wonderland`, + "m.mentions": {} + }, { + $type: "m.room.message", + msgtype: "m.notice", + body: "| ## ⏺️ minimus https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid\n| \n| reply draft\n| > The following is a message composed via consensus of the Stinker Council.\n| > \n| > For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.\n| > \n| > Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.\n| > \n| > There will be no further communication.\n| \n| [Go to Message](https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo?via=cadence.moe&via=example.invalid)", + format: "org.matrix.custom.html", + formatted_body: "

⏺️ minimus

reply draft

The following is a message composed via consensus of the Stinker Council.

For those who are not currently aware of our existence, we represent the organization known as Wonderland. Our previous mission centered around the assortment and study of puzzling objects, entities and other assorted phenomena. This mission was the focus of our organization for more than 28 years.

Due to circumstances outside of our control, this directive has now changed. Our new mission will be the extermination of the stinker race.

There will be no further communication.

Go to Message

", + "m.mentions": {} + }]) + t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") +}) + +test("message2event embeds: crazy html is all escaped", async t => { + const events = await messageToEvent(data.message_with_embeds.escaping_crazy_html_tags, data.guild.general) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.notice", + body: "| ## ⏺️ [Hey