diff --git a/.c8rc.json b/.c8rc.json deleted file mode 100644 index 5847f1af..00000000 --- a/.c8rc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "watermarks": { - "statements": [60, 100], - "lines": [60, 100], - "functions": [60, 100], - "branches": [60, 100] - } -} diff --git a/.gitignore b/.gitignore index 9c175d8f..9a310ad8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,3 @@ config.js registration.yaml coverage db/ooye.db* -test/res/* -!test/res/lottie* diff --git a/d2m/actions/announce-thread.js b/d2m/actions/announce-thread.js index 324c7a5b..86c64125 100644 --- a/d2m/actions/announce-thread.js +++ b/d2m/actions/announce-thread.js @@ -8,8 +8,6 @@ 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 @@ -17,10 +15,10 @@ const registerUser = sync.require("./register-user") * @param {import("discord-api-types/v10").APIThreadChannel} thread */ async function announceThread(parentRoomID, threadRoomID, thread) { - 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}) + const creatorMxid = select("sim", "mxid", {user_id: thread.owner_id}).pluck().get() + + const content = await threadToAnnouncement.threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, {api}) + await api.sendEvent(parentRoomID, "m.room.message", content, creatorMxid) } diff --git a/d2m/actions/create-space.js b/d2m/actions/create-space.js index 7d50199e..b30f6817 100644 --- a/d2m/actions/create-space.js +++ b/d2m/actions/create-space.js @@ -2,7 +2,6 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") -const deepEqual = require("deep-equal") const reg = require("../../matrix/read-registration") const passthrough = require("../../passthrough") @@ -13,8 +12,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("./expression")} */ -const expression = sync.require("./expression") +/** @type {import("../converters/expression")} */ +const expression = sync.require("../converters/expression") /** @type {import("../../matrix/kstate")} */ const ks = sync.require("../../matrix/kstate") @@ -22,7 +21,7 @@ const ks = sync.require("../../matrix/kstate") const inflightSpaceCreate = new Map() /** - * @param {DiscordTypes.RESTGetAPIGuildResult} guild + * @param {import("discord-api-types/v10").RESTGetAPIGuildResult} guild * @param {any} kstate */ async function createSpace(guild, kstate) { @@ -199,40 +198,23 @@ async function syncSpaceFully(guildID) { } /** - * @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 + * @param {import("discord-api-types/v10").GatewayGuildEmojisUpdateDispatchData | import("discord-api-types/v10").GatewayGuildStickersUpdateDispatchData} data */ -async function syncSpaceExpressions(data, checkBeforeSync) { +async function syncSpaceExpressions(data) { // 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 - /** - * @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 (deepEqual(existing, content, {strict: true})) return - } - api.sendState(spaceID, "im.ponies.room_emotes", eventKey, content) + 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) } - update(spaceID, "emojis", "moe.cadence.ooye.pack.emojis", expression.emojisToState) - update(spaceID, "stickers", "moe.cadence.ooye.pack.stickers", expression.stickersToState) + 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) + } } module.exports.createSpace = createSpace diff --git a/d2m/actions/delete-message.js b/d2m/actions/delete-message.js index 440e123c..30c31d47 100644 --- a/d2m/actions/delete-message.js +++ b/d2m/actions/delete-message.js @@ -1,46 +1,23 @@ // @ts-check const passthrough = require("../../passthrough") -const {sync, db, select, from} = passthrough +const {sync, db, select} = 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) - db.prepare("DELETE FROM event_message 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) - db.prepare("DELETE FROM event_message WHERE message_id IN (SELECT value FROM json_each(?)").run(sids) + const eventsToRedact = select("event_message", "event_id", {message_id: data.id}).pluck().all() 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 + // 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 -module.exports.deleteMessageBulk = deleteMessageBulk diff --git a/d2m/actions/edit-message.js b/d2m/actions/edit-message.js index d52fcbde..2a08526a 100644 --- a/d2m/actions/edit-message.js +++ b/d2m/actions/edit-message.js @@ -1,32 +1,18 @@ // @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, 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") { - const root = await registerPkUser.fetchMessage(message.id) - assert(root.member) - senderMxid = await registerPkUser.ensureSimJoined(root, roomID) - } - } +async function editMessage(message, guild) { + const {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api) // 1. Replace all the things. for (const {oldID, newContent} of eventsToReplace) { diff --git a/d2m/actions/lottie.js b/d2m/actions/lottie.js deleted file mode 100644 index 4635fed1..00000000 --- a/d2m/actions/lottie.js +++ /dev/null @@ -1,53 +0,0 @@ -// @ts-check - -const DiscordTypes = require("discord-api-types/v10") -const Ty = require("../../types") -const assert = require("assert").strict - -const passthrough = require("../../passthrough") -const {sync, db, select} = passthrough -/** @type {import("../../matrix/file")} */ -const file = sync.require("../../matrix/file") -/** @type {import("../../matrix/mreq")} */ -const mreq = sync.require("../../matrix/mreq") -/** @type {import("../converters/lottie")} */ -const convertLottie = sync.require("../converters/lottie") - -const INFO = { - mimetype: "image/png", - w: convertLottie.SIZE, - h: convertLottie.SIZE -} - -/** - * @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} - - // 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() - - // Convert to PNG (readable stream) - const readablePng = await convertLottie.convert(text) - - // Upload to MXC - /** @type {Ty.R.FileUploaded} */ - const root = await mreq.mreq("POST", "/media/v3/upload", readablePng, { - headers: { - "Content-Type": INFO.mimetype - } - }) - 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} -} - -module.exports.convert = convert diff --git a/d2m/actions/register-pk-user.js b/d2m/actions/register-pk-user.js deleted file mode 100644 index ca47b7c4..00000000 --- a/d2m/actions/register-pk-user.js +++ /dev/null @@ -1,153 +0,0 @@ -// @ts-check - -const assert = require("assert") -const reg = require("../../matrix/read-registration") -const Ty = require("../../types") -const fetch = require("node-fetch").default - -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("./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 - */ - -/** - * 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, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(pkMessage.member.uuid, 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. - // (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 -} - -/** - * @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. 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 {WebhookAuthor} author - * @param {Ty.PkMessage} pkMessage - * @param {string} roomID - * @returns {Promise} mxid of the updated sim - */ -async function syncUser(author, pkMessage, roomID) { - const mxid = await ensureSimJoined(pkMessage, roomID) - // Update the sim_proxy table, so mentions can look up the original sender later - db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username) - // Sync the member state - const content = await memberToStateContent(pkMessage, author) - const currentHash = registerUser._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 -} - -/** @returns {Promise} */ -function fetchMessage(messageID) { - return fetch(`https://api.pluralkit.me/v2/messages/${messageID}`).then(res => res.json()) -} - -module.exports._memberToStateContent = memberToStateContent -module.exports.ensureSim = ensureSim -module.exports.ensureSimJoined = ensureSimJoined -module.exports.syncUser = syncUser -module.exports.fetchMessage = fetchMessage diff --git a/d2m/actions/register-user.js b/d2m/actions/register-user.js index 8244fe23..9b5527f6 100644 --- a/d2m/actions/register-user.js +++ b/d2m/actions/register-user.js @@ -36,7 +36,7 @@ async function createSim(user) { 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.) + // (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 } @@ -123,7 +123,7 @@ async function memberToStateContent(user, member, guildID) { return content } -function _hashProfileContent(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 @@ -142,7 +142,7 @@ function _hashProfileContent(content) { 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 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) { @@ -179,7 +179,6 @@ async function syncAllUsersInRoom(roomID) { } module.exports._memberToStateContent = memberToStateContent -module.exports._hashProfileContent = _hashProfileContent module.exports.ensureSim = ensureSim module.exports.ensureSimJoined = ensureSimJoined module.exports.syncUser = syncUser diff --git a/d2m/actions/send-message.js b/d2m/actions/send-message.js index 8c26f071..b59fc7fc 100644 --- a/d2m/actions/send-message.js +++ b/d2m/actions/send-message.js @@ -10,8 +10,6 @@ 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")} */ @@ -20,9 +18,8 @@ const dUtils = sync.require("../../discord/utils") /** * @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 sendMessage(message, guild, row) { +async function sendMessage(message, guild) { const roomID = await createRoom.ensureRoom(message.channel_id) let senderMxid = null @@ -32,19 +29,6 @@ async function sendMessage(message, guild, row) { } else { // well, good enough... senderMxid = await registerUser.ensureSimJoined(message.author, roomID) } - } else if (row && row.speedbump_webhook_id === message.webhook_id) { - // Handle the PluralKit public instance - if (row.speedbump_id === "466378653216014359") { - const root = await registerPkUser.fetchMessage(message.id) - // Member is null if member was deleted. We just got this message, so member surely exists. - if (!root.member) { - const e = new Error("PK API did not return a member") - message["__pk_response__"] = root - console.error(root) - throw e - } - senderMxid = await registerPkUser.syncUser(message.author, root, roomID) - } } const events = await messageToEvent.messageToEvent(message, guild, {}, {api}) diff --git a/d2m/actions/speedbump.js b/d2m/actions/speedbump.js deleted file mode 100644 index 7c3109b1..00000000 --- a/d2m/actions/speedbump.js +++ /dev/null @@ -1,68 +0,0 @@ -// @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/d2m/actions/update-pins.js b/d2m/actions/update-pins.js index 5d985018..40cc3581 100644 --- a/d2m/actions/update-pins.js +++ b/d2m/actions/update-pins.js @@ -1,37 +1,22 @@ // @ts-check const passthrough = require("../../passthrough") -const {discord, sync, db} = 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") -/** - * @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) { +async function updatePins(channelID, roomID) { const pins = await discord.snow.channel.getChannelPinnedMessages(channelID) const eventIDs = pinsToList.pinsToList(pins) - if (pins.length === eventIDs.length || eventIDs.length) { - await api.sendState(roomID, "m.room.pinned_events", "", { - pinned: eventIDs - }) - } - db.prepare("UPDATE channel_room SET last_bridged_pin_timestamp = ? WHERE channel_id = ?").run(convertedTimestamp || 0, channelID) + await api.sendState(roomID, "m.room.pinned_events", "", { + pinned: eventIDs + }) } -module.exports.convertTimestamp = convertTimestamp module.exports.updatePins = updatePins diff --git a/d2m/converters/edit-to-changes.js b/d2m/converters/edit-to-changes.js index 08f787cf..f86921c4 100644 --- a/d2m/converters/edit-to-changes.js +++ b/d2m/converters/edit-to-changes.js @@ -93,11 +93,7 @@ async function editToChanges(message, guild, api) { // 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)) - if (column === "part") { - promotions.push({column, eventID: eventsToReplace[0].old.event_id}) // part should be the first one - } else { - promotions.push({column, eventID: eventsToReplace[eventsToReplace.length - 1].old.event_id}) // reaction_part should be the last one - } + 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}) diff --git a/d2m/converters/edit-to-changes.test.js b/d2m/converters/edit-to-changes.test.js index 04f5568c..7c29787f 100644 --- a/d2m/converters/edit-to-changes.test.js +++ b/d2m/converters/edit-to-changes.test.js @@ -175,63 +175,3 @@ test("edit2changes: edit of reply to skull webp attachment with content", async } }]) }) - -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, {}) - 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, {}) - 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" - } - ]) -}) diff --git a/d2m/actions/expression.js b/d2m/converters/expression.js similarity index 96% rename from d2m/actions/expression.js rename to d2m/converters/expression.js index b7b5d5a7..1c52c983 100644 --- a/d2m/actions/expression.js +++ b/d2m/converters/expression.js @@ -1,9 +1,10 @@ // @ts-check +const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../../passthrough") -const {sync, db} = passthrough +const {discord, sync, db, select} = passthrough /** @type {import("../../matrix/file")} */ const file = sync.require("../../matrix/file") diff --git a/d2m/converters/lottie.js b/d2m/converters/lottie.js index 6d13f70b..a0d1cd18 100644 --- a/d2m/converters/lottie.js +++ b/d2m/converters/lottie.js @@ -1,11 +1,25 @@ // @ts-check -const assert = require("assert") -const stream = require("stream") +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 +/** @type {import("../../matrix/file")} */ +const file = sync.require("../../matrix/file") +//** @type {import("../../matrix/mreq")} */ +const mreq = sync.require("../../matrix/mreq") + const SIZE = 160 // Discord's display size on 1x displays is 160 +const INFO = { + mimetype: "image/png", + w: SIZE, + h: SIZE +} + /** * @typedef RlottieWasm * @prop {(string) => boolean} load load lottie data from string of json @@ -20,15 +34,20 @@ const Rlottie = (async () => { })() /** - * @param {string} text - * @returns {Promise} + * @param {DiscordTypes.APIStickerItem} stickerItem + * @returns {Promise<{mxc_url: string, info: typeof INFO}>} */ -async function convert(text) { +async function convert(stickerItem) { + const existingMxc = select("lottie", "mxc_url", {sticker_id: stickerItem.id}).pluck().get() + if (existingMxc) return {mxc_url: existingMxc, info: INFO} const r = await Rlottie + 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) - assert(status, `Rlottie unable to load ${text.length} byte data file.`) + 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, @@ -39,10 +58,17 @@ async function convert(text) { 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. - return stream.Duplex.from(png.pack()) + // @ts-ignore wrong type from pngjs + const readablePng = png.pack() + /** @type {Ty.R.FileUploaded} */ + const root = await mreq.mreq("POST", "/media/v3/upload", readablePng, { + headers: { + "Content-Type": INFO.mimetype + } + }) + assert(root.content_uri) + db.prepare("INSERT INTO lottie (sticker_id, mxc_url) VALUES (?, ?)").run(stickerItem.id, root.content_uri) + return {mxc_url: root.content_uri, info: INFO} } module.exports.convert = convert -module.exports.SIZE = SIZE diff --git a/d2m/converters/lottie.test.js b/d2m/converters/lottie.test.js deleted file mode 100644 index 9d9255b4..00000000 --- a/d2m/converters/lottie.test.js +++ /dev/null @@ -1,34 +0,0 @@ -// @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/d2m/converters/message-to-event.embeds.test.js b/d2m/converters/message-to-event.embeds.test.js index dfc5679c..93d189c4 100644 --- a/d2m/converters/message-to-event.embeds.test.js +++ b/d2m/converters/message-to-event.embeds.test.js @@ -3,20 +3,46 @@ 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", + 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:\"' + formatted_body: '

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

' + + '
❯ Memory:
64.45MB
' }]) }) @@ -26,19 +52,19 @@ test("message2event embeds: reply with just an embed", async t => { $type: "m.room.message", msgtype: "m.notice", "m.mentions": {}, - body: "| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145" - + "\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| — Twitter", + 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
' + 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
' }]) }) @@ -73,71 +99,3 @@ test("message2event embeds: image embed and attachment", async t => { "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