diff --git a/docs/get-started.md b/docs/get-started.md index 5b14b2a..17c98de 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -56,8 +56,16 @@ After that, to get into the rooms on your Matrix account, use the invite form on I hope you enjoy Out Of Your Element! ----- -




+# Extras + +## /plu/ral support + +To support /plu/ral webhook proxying, OOYE needs to call the /plu/ral API authenticated. I can't provide an API key for everyone to share, so you have to make your own. Here's how: + +1. Open the [/plu/ral app on Discord](https://discord.com/discovery/applications/1291501048493768784) and "Add App" -> "Add to My Apps (use everywhere)" +2. Go to any text channel on Discord and use the new `/api` command from /plu/ral. +3. Create a new application named Out Of Your Element. Copy the displayed token. +3. Run `node scripts/plu-ral-api.js` and paste the token. # Appendix diff --git a/scripts/plu-ral-api.js b/scripts/plu-ral-api.js new file mode 100644 index 0000000..d3468a3 --- /dev/null +++ b/scripts/plu-ral-api.js @@ -0,0 +1,17 @@ +// @ts-check + +const {reg, writeRegistration} = require("../src/matrix/read-registration") +const {prompt} = require("enquirer") + +;(async () => { + /** @type {{api_key: string}} */ + const apiKeyResponse = await prompt({ + type: "text", + name: "api_key", + message: "Paste your personal /plu/ral API key" + }) + + reg.ooye.plu_ral_api_key = apiKeyResponse.api_key + writeRegistration(reg) + console.log("Saved. This change should be applied instantly.") +})() diff --git a/src/d2m/actions/edit-message.js b/src/d2m/actions/edit-message.js index f86a9c8..31f3627 100644 --- a/src/d2m/actions/edit-message.js +++ b/src/d2m/actions/edit-message.js @@ -1,24 +1,24 @@ // @ts-check -const assert = require("assert").strict - const passthrough = require("../../passthrough") const {sync, db, select, from} = passthrough +const {reg} = require("../../matrix/read-registration") /** @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("./speedbump")} */ +const speedbump = sync.require("./speedbump") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") /** @type {import("../../matrix/mreq")} */ const mreq = sync.require("../../matrix/mreq") +/** @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 {{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) { +async function editMessage(message, guild) { const historicalRoomOfMessage = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: message.id}).select("room_id").get() const currentRoom = from("channel_room").join("historical_channel_room", "room_id").where({channel_id: message.channel_id}).select("room_id", "historical_room_index").get() if (!currentRoom) return @@ -27,11 +27,9 @@ 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, true) - } + // Sync proxy user profile (if sent by proxy) + if (dUtils.isWebhookMessage(message)) { + senderMxid = await speedbump.getWebhookSenderId(message, guild.id, roomID) } // 1. Replace all the things. diff --git a/src/d2m/actions/register-pk-user.js b/src/d2m/actions/register-pk-user.js index 6ecd077..4946d4f 100644 --- a/src/d2m/actions/register-pk-user.js +++ b/src/d2m/actions/register-pk-user.js @@ -1,6 +1,6 @@ // @ts-check -const assert = require("assert") +const assert = require("assert").strict const {reg} = require("../../matrix/read-registration") const Ty = require("../../types") @@ -132,10 +132,10 @@ 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) + db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname, proxy_app) VALUES (?, ?, ?, 0)").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() + const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID, proxy_app: 0}).pluck("mxid").get() if (!senderMxid) throw e return senderMxid } diff --git a/src/d2m/actions/register-plu-ral-user.js b/src/d2m/actions/register-plu-ral-user.js new file mode 100644 index 0000000..04fa28c --- /dev/null +++ b/src/d2m/actions/register-plu-ral-user.js @@ -0,0 +1,170 @@ +// @ts-check + +const assert = require("assert").strict +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") + +/** @returns {Promise} */ +async function fetchMessage(channelID, messageID) { + assert(reg.ooye.plu_ral_api_key) + try { + var res = await fetch(`https://api.plural.gg/messages/${channelID}/${messageID}?member=true`, { + headers: { + Authorization: reg.ooye.plu_ral_api_key + } + }) + } catch (networkError) { + // Network issue, raise a more readable message + throw new Error(`Failed to connect to /plu/ral API: ${networkError.toString()}`) + } + if (!res.ok) throw new Error(`/plu/ral API returned an error: ${await res.text()}`) + /** @type {any} */ + const root = await res.json() + if (!root.member) throw new Error(`/plu/ral API didn't return member data: ${JSON.stringify(root)}`) + return root +} + +/** + * Using the same sim names and fake user IDs for /plu/ral members, since unlike PluralKit they don't have a short and a long ID. + * @param {Ty.PluRalWebhookMessage} pluRalMessage + */ +function getSimName(pluRalMessage) { + return `_pl_${pluRalMessage.member_id}` +} + +/** + * A sim is an account that is being simulated by the bridge to copy events from the other side. + * @param {Ty.PluRalWebhookMessage} pluRalMessage + * @returns mxid + */ +async function createSim(pluRalMessage) { + // Choose sim name + const simName = getSimName(pluRalMessage) + 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(simName, 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(simName) + 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.PluRalWebhookMessage} pluRalMessage + * @returns {Promise} mxid + */ +async function ensureSim(pluRalMessage) { + let mxid = null + const existing = select("sim", "mxid", {user_id: getSimName(pluRalMessage)}).pluck().get() + if (existing) { + mxid = existing + } else { + mxid = await createSim(pluRalMessage) + } + return mxid +} + +/** + * Ensure a sim is registered for the user and is joined to the room. + * @param {Ty.PluRalWebhookMessage} pluRalMessage + * @param {string} roomID + * @returns {Promise} mxid + */ +async function ensureSimJoined(pluRalMessage, roomID) { + // Ensure room ID is really an ID, not an alias + assert.ok(roomID[0] === "!") + + // Ensure user + const mxid = await ensureSim(pluRalMessage) + + // Ensure joined + const existing = select("sim_member", "mxid", {room_id: roomID, mxid}).pluck().get() + if (!existing) { + await api.inviteToRoom(roomID, mxid) + await api.joinRoom(roomID, mxid) + 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.PluRalWebhookMessage} pluRalMessage + * @param {Ty.WebhookAuthor} author + */ +async function memberToStateContent(pluRalMessage, 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 = pluRalMessage.member.avatar_url || file.userAvatar(author) + + const content = { + displayname: author.username, + membership: "join", + "moe.cadence.ooye.plu_ral_member": pluRalMessage.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} channelID to call API with + * @param {string} messageID to call API with + * @param {Ty.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(channelID, messageID, author, roomID, shouldActuallySync) { + try { + // API lookup + var pluRalMessage = await fetchMessage(channelID, messageID) + const simName = getSimName(pluRalMessage) + db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname, proxy_app) VALUES (?, ?, ?, 1)").run(simName, pluRalMessage.author_id, 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, proxy_app: 1}).pluck("mxid").get() + if (!senderMxid) throw e + return senderMxid + } + + // Create and join the sim to the room if needed + const mxid = await ensureSimJoined(pluRalMessage, roomID) + + if (shouldActuallySync) { + // Build current profile data and sync if the hash has changed + const content = await memberToStateContent(pluRalMessage, author) + await registerUser._sendSyncUser(roomID, mxid, content, null) + } + + return mxid +} + +module.exports.syncUser = syncUser diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index 5b3b4f3..41ef70d 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -4,17 +4,16 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../../passthrough") -const { discord, sync, db, select, from} = passthrough +const {discord, sync, db, select, from} = passthrough +const {reg} = require("../../matrix/read-registration") /** @type {import("../converters/message-to-event")} */ const messageToEvent = sync.require("../converters/message-to-event") /** @type {import("../../matrix/api")} */ 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("./register-webhook-user")} */ -const registerWebhookUser = sync.require("./register-webhook-user") +/** @type {import("./speedbump")} */ +const speedbump = sync.require("./speedbump") /** @type {import("../actions/create-room")} */ const createRoom = sync.require("../actions/create-room") /** @type {import("../actions/poll-end")} */ @@ -28,24 +27,15 @@ const channelWebhook = sync.require("../../m2d/actions/channel-webhook") * @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, channel, guild, row) { +async function sendMessage(message, channel, guild) { const roomID = await createRoom.ensureRoom(message.channel_id) const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get() assert(historicalRoomIndex) let senderMxid = null if (dUtils.isWebhookMessage(message)) { - const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guild.id}).pluck().get() ?? 0 - 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) - } - } else if (useWebhookProfile) { - senderMxid = await registerWebhookUser.syncUser(message.author, roomID, true) - } + senderMxid = await speedbump.getWebhookSenderId(message, guild.id, roomID) } else { // not a webhook if (message.author.id === discord.application.id) { diff --git a/src/d2m/actions/speedbump.js b/src/d2m/actions/speedbump.js index 42e3a35..88cea07 100644 --- a/src/d2m/actions/speedbump.js +++ b/src/d2m/actions/speedbump.js @@ -1,13 +1,23 @@ // @ts-check +const DiscordTypes = require("discord-api-types/v10") + const passthrough = require("../../passthrough") -const {discord, select, db} = passthrough +const {discord, from, select, db, sync} = passthrough + +const {reg} = require("../../matrix/read-registration") +/** @type {import("./register-pk-user")} */ +const registerPkUser = sync.require("./register-pk-user") +/** @type {import("./register-plu-ral-user")} */ +const registerPluRalUser = sync.require("./register-plu-ral-user") +/** @type {import("./register-webhook-user")} */ +const registerWebhookUser = sync.require("./register-webhook-user") const DEBUG_SPEEDBUMP = false -function debugSpeedbump(message) { +function debugSpeedbump(...args) { if (DEBUG_SPEEDBUMP) { - console.log(message) + console.log(...args) } } @@ -16,7 +26,8 @@ const SPEEDBUMP_UPDATE_FREQUENCY = 2 * 60 * 60 // 2 hours /** @type {Set} */ const KNOWN_BOTS = new Set([ - "466378653216014359" // PluralKit + "466378653216014359", // PluralKit + "1291501048493768784", // /plu/ral ]) /** @@ -28,61 +39,81 @@ 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) + const found = webhooks.filter(b => KNOWN_BOTS.has(b.application_id)) + db.transaction(() => { + db.prepare("DELETE FROM channel_speedbump WHERE channel_id = ?").run(channelID) + for (const webhook of found) { + db.prepare("INSERT INTO channel_speedbump (channel_id, speedbump_webhook_id, speedbump_user_id) VALUES (?, ?, ?)").run(channelID, webhook.id, webhook.application_id) + } + db.prepare("UPDATE channel_room SET speedbump_checked = ? WHERE channel_id = ?").run(now, channelID) + })() } -/** @type {Map} messageID -> number of gateway events currently bumping */ +/** + * @typedef BumpingEntry + * @prop {number} number number of gateway events currently bumping for this message ID + * @prop {boolean} hasCreate whether there was a message create within the events currently bumping + */ + +/** @type {Map} messageID -> BumpingEntry */ const bumping = new Map() /** * Slow down a message. After it passes the speedbump, return whether it's okay or if it's been deleted. + * @param {boolean} isCreate * @param {string} messageID * @returns whether it was deleted */ -async function doSpeedbump(messageID) { - let value = (bumping.get(messageID) ?? 0) + 1 - bumping.set(messageID, value) - debugSpeedbump(`[speedbump] WAIT ${messageID}++ = ${value}`) +async function doSpeedbump(isCreate, messageID) { + const entry = bumping.get(messageID) ?? (() => { + const entry = {number: 0, hasCreate: false} + bumping.set(messageID, entry) + return entry + })() + + entry.number++ + entry.hasCreate ||= isCreate + + debugSpeedbump(`[speedbump] WAIT ${messageID}++ =`, entry) await new Promise(resolve => setTimeout(resolve, SPEEDBUMP_SPEED)) if (!bumping.has(messageID)) { debugSpeedbump(`[speedbump] DELETED ${messageID}`) - return true + return {skip: true, hasCreate: null} } - value = (bumping.get(messageID) ?? 0) - 1 - if (value <= 0) { - debugSpeedbump(`[speedbump] OK ${messageID}-- = ${value}`) + + if (--entry.number <= 0) { + debugSpeedbump(`[speedbump] OK ${messageID}-- =`, entry) bumping.delete(messageID) - return false + return {skip: false, hasCreate: entry.hasCreate} } else { - debugSpeedbump(`[speedbump] MULTI ${messageID}-- = ${value}`) - bumping.set(messageID, value) - return true + debugSpeedbump(`[speedbump] MULTI ${messageID}-- =`, entry) + return {skip: true, hasCreate: null} } } +function getSpeedbumpRows(channelID) { + return from("channel_room").join("channel_speedbump", "channel_id").select("thread_parent", "speedbump_user_id", "speedbump_webhook_id").where({channel_id: channelID}).all() +} + /** * Check whether to slow down a message, and do it. After it passes the speedbump, return whether it's okay or if it's been deleted. - * @param {string} channelID - * @param {string} messageID - * @param {string} [userID] if provided, only slow down the message when the user has used PK before - * @returns whether it was deleted, and data about the channel's (not thread's) speedbump + * @param {boolean} isCreate + * @param {{id: string, channel_id: string, author: {id: string}, backfill?: boolean}} message uses the ID to identify, and the userID to only slow down the message when the user has used PK before + * @returns whether to skip this message, and whether the message should be created as a creation */ -async function maybeDoSpeedbump(channelID, messageID, userID) { - let row = select("channel_room", ["room_id", "thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: channelID}).get() - if (row?.thread_parent) row = select("channel_room", ["room_id", "thread_parent", "speedbump_id", "speedbump_webhook_id"], {channel_id: row.thread_parent}).get() // webhooks belong to the channel, not the thread - if (!row?.speedbump_webhook_id) return {affected: false, row: null} // channel not affected, no speedbump - if (userID) { - if (row.speedbump_webhook_id === userID) return {affected: false, row} // shortcut - const userHasProxy = select("sim_proxy", "user_id", {proxy_owner_id: userID}).pluck().get() - if (!userHasProxy) return {affected: false, row} // user has not used PK before, no speedbump - } - const affected = await doSpeedbump(messageID) - return {affected, row} // maybe affected, and there is a speedbump +async function maybeDoSpeedbump(isCreate, message) { + let rows = getSpeedbumpRows(message.channel_id) + if (rows[0]?.thread_parent) rows = getSpeedbumpRows(rows[0].thread_parent) // webhooks belong to the channel, not the thread + if (!rows.length) return {skip: false} // channel not affected, no speedbump + + if (message.backfill) return {skip: false} // don't slow messages during backfill + if (rows.some(r => r.speedbump_webhook_id === message.author.id)) return {skip: false} // shortcut + const userHasProxy = select("sim_proxy", "user_id", {proxy_owner_id: message.author.id}).pluck().get() + if (!userHasProxy) return {skip: false} // user has not used PK before, no speedbump + const {skip, hasCreate} = await doSpeedbump(isCreate, message.id) + return {skip, hasCreate} // maybe affected, and there is a speedbump } /** @@ -92,7 +123,25 @@ function onMessageDelete(messageID) { bumping.delete(messageID) } +/** + * @param {DiscordTypes.APIMessage} message + * @param {string} guildID + * @param {string} roomID + */ +async function getWebhookSenderId(message, guildID, roomID) { + const speedbumpUserID = select("channel_speedbump", "speedbump_user_id", {channel_id: message.channel_id, speedbump_webhook_id: message.webhook_id}).pluck().get() + const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guildID}).pluck().get() ?? 0 + if (speedbumpUserID === "466378653216014359") { // PluralKit public instance + return await registerPkUser.syncUser(message.id, message.author, roomID, true) + } else if (speedbumpUserID === "1291501048493768784" && reg.ooye.plu_ral_api_key) { // /plu/ral public instance + return await registerPluRalUser.syncUser(message.channel_id, message.id, message.author, roomID, true) + } else if (useWebhookProfile) { + return await registerWebhookUser.syncUser(message.author, roomID, true) + } + return null +} + module.exports.updateCache = updateCache -module.exports.doSpeedbump = doSpeedbump module.exports.maybeDoSpeedbump = maybeDoSpeedbump module.exports.onMessageDelete = onMessageDelete +module.exports.getWebhookSenderId = getWebhookSenderId diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index c0c33d9..a49453a 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -288,7 +288,6 @@ module.exports = { if (!guildID) return // channel must have been a DM channel or something const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get() if (!roomID) return // channel wasn't being bridged in the first place - // @ts-ignore await createRoom.unbridgeChannel(channel, guildID) }, @@ -313,11 +312,10 @@ module.exports = { if (!createRoom.existsOrAutocreatable(channel, guild.id)) return // Check that the sending-to room exists or is autocreatable - const {affected, row} = await speedbump.maybeDoSpeedbump(message.channel_id, message.id, message.author.id) - if (affected) return + const {skip} = await speedbump.maybeDoSpeedbump(true, message) + if (skip) return - // @ts-ignore - await sendMessage.sendMessage(message, channel, guild, row) + await sendMessage.sendMessage(message, channel, guild) retrigger.finishedBridging(message.id) }, @@ -335,22 +333,27 @@ module.exports = { if (dUtils.isEphemeralMessage(data)) return // Ephemeral messages are for the eyes of the receiver only! // Edits need to go through the speedbump as well. If the message is delayed but the edit isn't, we don't have anything to edit from. - const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id, data.author.id) - if (affected) return - - // Check that the sending-to room exists, and deal with Eventual Consistency(TM) - if (!await retrigger.waitForMessage(data.id)) return + const {skip, hasCreate} = await speedbump.maybeDoSpeedbump(false, data) + if (skip) return /** @type {DiscordTypes.GatewayMessageCreateDispatchData} */ - // @ts-ignore const message = data const channel = client.channels.get(message.channel_id) if (!channel || !("guild_id" in channel) || !channel.guild_id) return // Nothing we can do in direct messages. const guild = client.guilds.get(channel.guild_id) assert(guild) - // @ts-ignore - await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row)) + if (!hasCreate) { + // Standard path for most message updates + // Check that the target message already exists, and deal with Eventual Consistency(TM) + if (!await retrigger.waitForMessage(data.id)) return + await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild)) + } + else { + // Path for edit packets that were speedbumped into the latest copy of a message that needs to be created + // Just pretend to be MESSAGE_CREATE + await sendMessage.sendMessage(message, channel, guild) + } }, /** diff --git a/src/db/migrations/0039-refactor-speedbump.sql b/src/db/migrations/0039-refactor-speedbump.sql new file mode 100644 index 0000000..de739dc --- /dev/null +++ b/src/db/migrations/0039-refactor-speedbump.sql @@ -0,0 +1,19 @@ +BEGIN TRANSACTION; + +CREATE TABLE "channel_speedbump" ( + "channel_id" TEXT NOT NULL, + "speedbump_webhook_id" TEXT NOT NULL, + "speedbump_user_id" TEXT NOT NULL, + PRIMARY KEY("channel_id","speedbump_webhook_id"), + FOREIGN KEY("channel_id") REFERENCES "channel_room"("channel_id") +) WITHOUT ROWID; + +INSERT INTO channel_speedbump (channel_id, speedbump_webhook_id, speedbump_user_id) + SELECT channel_id, speedbump_webhook_id, speedbump_id FROM channel_room WHERE speedbump_id IS NOT NULL AND speedbump_webhook_id IS NOT NULL; + +ALTER TABLE channel_room DROP COLUMN speedbump_id; +ALTER TABLE channel_room DROP COLUMN speedbump_webhook_id; + +ALTER TABLE sim_proxy ADD COLUMN proxy_app INTEGER DEFAULT 0; + +COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index d95bfc3..14f320a 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -18,13 +18,17 @@ export type Models = { thread_parent: string | null custom_avatar: string | null last_bridged_pin_timestamp: number | null - speedbump_id: string | null - speedbump_webhook_id: string | null speedbump_checked: number | null guild_id: string | null custom_topic: number } + channel_speedbump: { + channel_id: string + speedbump_webhook_id: string + speedbump_user_id: string + } + direct: { mxid: string room_id: string @@ -44,6 +48,7 @@ export type Models = { event_subtype: string | null part: number reaction_part: number + /** 0 = Matrix, 1 = Discord */ source: number } @@ -137,6 +142,8 @@ export type Models = { user_id: string proxy_owner_id: string displayname: string + /** 0 = PluralKit, 1 = /plu/ral */ + proxy_app: number } webhook: { diff --git a/src/matrix/mreq.js b/src/matrix/mreq.js index bf238f2..b6bc5b9 100644 --- a/src/matrix/mreq.js +++ b/src/matrix/mreq.js @@ -9,8 +9,10 @@ const {reg} = require("./read-registration.js") const baseUrl = `${reg.ooye.server_origin}/_matrix` class MatrixServerError extends Error { - /** @param {number} httpStatus} */ - constructor(data, httpStatus, opts) { + /** + * @param {number} httpStatus} + */ + constructor(data, httpStatus, opts = {}) { super(data.error || data.errcode) this.data = data /** @type {number} */ diff --git a/src/types.d.ts b/src/types.d.ts index 856ab1c..f65ce59 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -36,6 +36,7 @@ export type AppServiceRegistrationConfig = { web_password: string time_zone?: string receive_presences: boolean + plu_ral_api_key?: string } old_bridge?: { as_token: string @@ -124,6 +125,28 @@ export type PkMessage = { sender: string } +export type PluRalWebhookMessage = { + original_id: string | null + proxy_id: string + author_id: string + channel_id: string + member_id: string + reason: string + webhook_id: string + member: PluRalMember +} + +export type PluRalMember = { + id: string + name: string + pronouns: string + bio: string + birthday: string + color: number | null + avatar_url: string + private: boolean +} + export namespace Event { export type Outer = { type: string diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index 0182093..32f89bf 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -73,7 +73,7 @@ test("web link space: check that OOYE is joined", async t => { api: { async joinRoom(roomID) { called++ - throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) + throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400) } } })) @@ -368,7 +368,7 @@ test("web link room: check that bridge can join room (notices lack of via and as api: { async joinRoom(roomID) { called++ - throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) + throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400) }, async *generateFullHierarchy(spaceID) { called++ @@ -402,7 +402,7 @@ test("web link room: check that bridge can join room (uses via for join attempt) async joinRoom(roomID, _, via) { called++ t.deepEqual(via, ["cadence.moe", "hashi.re"]) - throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}) + throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"}, 400) }, async *generateFullHierarchy(spaceID) { called++ @@ -710,7 +710,7 @@ test("web unlink room: checks that the channel is bridged", async t => { })) t.equal(error.data, "Channel ID 665310973967597573 is not currently bridged") - db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_id, speedbump_checked, speedbump_webhook_id, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_id, row.speedbump_checked, row.speedbump_webhook_id, row.guild_id, row.custom_topic) + db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, custom_avatar, last_bridged_pin_timestamp, speedbump_checked, guild_id, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(row.channel_id, row.room_id, row.name, row.nick, row.thread_parent, row.custom_avatar, row.last_bridged_pin_timestamp, row.speedbump_checked, row.guild_id, row.custom_topic) const new_row = db.prepare("SELECT * FROM channel_room WHERE channel_id = '665310973967597573'").get() t.deepEqual(row, new_row) })