From 11864f80cfd7a40277336bf921b7df979f878de8 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 21 Jan 2024 00:54:18 +1300 Subject: [PATCH] d->m: Make PK members appear real --- d2m/actions/edit-message.js | 18 ++- d2m/actions/register-pk-user.js | 139 ++++++++++++++++++++++++ d2m/actions/register-user.js | 5 +- d2m/actions/send-message.js | 12 +- d2m/actions/speedbump.js | 6 +- d2m/event-dispatcher.js | 36 +++--- db/migrations/0009-add-speedbump-id.sql | 1 + db/orm-defs.d.ts | 1 + types.d.ts | 20 ++++ 9 files changed, 215 insertions(+), 23 deletions(-) create mode 100644 d2m/actions/register-pk-user.js diff --git a/d2m/actions/edit-message.js b/d2m/actions/edit-message.js index 2a08526..d8c5f97 100644 --- a/d2m/actions/edit-message.js +++ b/d2m/actions/edit-message.js @@ -1,18 +1,32 @@ // @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") { + const root = await registerPkUser.fetchMessage(message.id) + assert(root.member) + senderMxid = await registerPkUser.ensureSimJoined(root.member, roomID) + } + } // 1. Replace all the things. for (const {oldID, newContent} of eventsToReplace) { diff --git a/d2m/actions/register-pk-user.js b/d2m/actions/register-pk-user.js new file mode 100644 index 0000000..f0fd492 --- /dev/null +++ b/d2m/actions/register-pk-user.js @@ -0,0 +1,139 @@ +// @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") + +/** + * A sim is an account that is being simulated by the bridge to copy events from the other side. + * @param {Ty.PkMember} member + * @returns mxid + */ +async function createSim(member) { + // Choose sim name + const simName = "_pk_" + 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(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(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.PkMember} member + * @returns {Promise} mxid + */ +async function ensureSim(member) { + let mxid = null + const existing = select("sim", "mxid", {user_id: member.uuid}).pluck().get() + if (existing) { + mxid = existing + } else { + mxid = await createSim(member) + } + return mxid +} + +/** + * Ensure a sim is registered for the user and is joined to the room. + * @param {Ty.PkMember} member + * @param {string} roomID + * @returns {Promise} mxid + */ +async function ensureSimJoined(member, roomID) { + // Ensure room ID is really an ID, not an alias + assert.ok(roomID[0] === "!") + + // Ensure user + const mxid = await ensureSim(member) + + // 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.PkMember} member + */ +async function memberToStateContent(member) { + const displayname = member.display_name || member.name + const avatar = member.avatar_url || member.webhook_avatar_url + + const content = { + displayname, + membership: "join", + "moe.cadence.ooye.pk_member": 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 {Ty.PkMember} member + * @returns {Promise} mxid of the updated sim + */ +async function syncUser(member, roomID) { + const mxid = await ensureSimJoined(member, roomID) + const content = await memberToStateContent(member) + 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<{member?: Ty.PkMember}>} */ +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 b605c3a..8244fe2 100644 --- a/d2m/actions/register-user.js +++ b/d2m/actions/register-user.js @@ -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,6 +179,7 @@ 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 b59fc7f..8d02c43 100644 --- a/d2m/actions/send-message.js +++ b/d2m/actions/send-message.js @@ -10,6 +10,8 @@ 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")} */ @@ -18,8 +20,9 @@ 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) { +async function sendMessage(message, guild, row) { const roomID = await createRoom.ensureRoom(message.channel_id) let senderMxid = null @@ -29,6 +32,13 @@ async function sendMessage(message, guild) { } 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) + assert(root.member) // Member is null if member was deleted. We just got this message, so member surely exists. + senderMxid = await registerPkUser.syncUser(root.member, roomID) + } } const events = await messageToEvent.messageToEvent(message, guild, {}, {api}) diff --git a/d2m/actions/speedbump.js b/d2m/actions/speedbump.js index b56cbf6..ac1ce67 100644 --- a/d2m/actions/speedbump.js +++ b/d2m/actions/speedbump.js @@ -22,8 +22,10 @@ async function updateCache(channelID, speedbumpID, speedbumpChecked) { const now = Math.floor(Date.now() / 1000) if (speedbumpChecked && now - speedbumpChecked < SPEEDBUMP_UPDATE_FREQUENCY) return const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID) - const found = webhooks.find(b => KNOWN_BOTS.has(b.application_id))?.application_id || null - db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(found, now, 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 */ diff --git a/d2m/event-dispatcher.js b/d2m/event-dispatcher.js index 3a80d0a..6003152 100644 --- a/d2m/event-dispatcher.js +++ b/d2m/event-dispatcher.js @@ -236,22 +236,22 @@ module.exports = { */ 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) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. - } else { - const speedbumpID = select("channel_room", "speedbump_id", {channel_id: message.channel_id}).pluck().get() - if (speedbumpID) { - const affected = await speedbump.doSpeedbump(message.id) - if (affected) return - } - } 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) - await sendMessage.sendMessage(message, guild), + const row = select("channel_room", ["speedbump_id", "speedbump_webhook_id"], {channel_id: message.channel_id}).get() + if (message.webhook_id) { + const row = select("webhook", "webhook_id", {webhook_id: message.webhook_id}).pluck().get() + if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. + } else if (row) { + const affected = await speedbump.doSpeedbump(message.id) + if (affected) return + } + + // @ts-ignore + await sendMessage.sendMessage(message, guild, row), await discordCommandHandler.execute(message, channel, guild) }, @@ -260,13 +260,16 @@ module.exports = { * @param {DiscordTypes.GatewayMessageUpdateDispatchData} data */ async onMessageUpdate(client, data) { + const row = select("channel_room", ["speedbump_id", "speedbump_webhook_id"], {channel_id: data.channel_id}).get() 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 - } + if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it. + } else if (row) { + // 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 = await speedbump.doSpeedbump(data.id) + if (affected) 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") { @@ -277,7 +280,8 @@ module.exports = { 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) - await editMessage.editMessage(message, guild) + // @ts-ignore + await editMessage.editMessage(message, guild, row) } }, diff --git a/db/migrations/0009-add-speedbump-id.sql b/db/migrations/0009-add-speedbump-id.sql index e971146..67a415c 100644 --- a/db/migrations/0009-add-speedbump-id.sql +++ b/db/migrations/0009-add-speedbump-id.sql @@ -1,6 +1,7 @@ BEGIN TRANSACTION; ALTER TABLE channel_room ADD COLUMN speedbump_id TEXT; +ALTER TABLE channel_room ADD COLUMN speedbump_webhook_id TEXT; ALTER TABLE channel_room ADD COLUMN speedbump_checked INTEGER; COMMIT; diff --git a/db/orm-defs.d.ts b/db/orm-defs.d.ts index f0f9a67..540c7a6 100644 --- a/db/orm-defs.d.ts +++ b/db/orm-defs.d.ts @@ -8,6 +8,7 @@ export type Models = { custom_avatar: string | null last_bridged_pin_timestamp: number | null speedbump_id: string | null + speedbump_webhook_id: string | null speedbump_checked: number | null } diff --git a/types.d.ts b/types.d.ts index 9e9d72b..daf62ad 100644 --- a/types.d.ts +++ b/types.d.ts @@ -34,6 +34,26 @@ export type WebhookCreds = { token: string } +export type PkMember = { + id: string + uuid: string + name: string + display_name: string | null + color: string | null + birthday: string | null + pronouns: string | null + avatar_url: string | null + webhook_avatar_url: string | null + banner: string | null + description: string | null + created: string | null + keep_proxy: boolean + tts: boolean + autoproxy_enabled: boolean | null + message_count: number | null + last_message_timestamp: string +} + export namespace Event { export type Outer = { type: string