d->m: Make PK members appear real

This commit is contained in:
Cadence Ember 2024-01-21 00:54:18 +13:00
parent 988cb9408d
commit 11864f80cf
9 changed files with 215 additions and 23 deletions

View file

@ -1,18 +1,32 @@
// @ts-check // @ts-check
const assert = require("assert").strict
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, db, select} = passthrough const {sync, db, select} = passthrough
/** @type {import("../converters/edit-to-changes")} */ /** @type {import("../converters/edit-to-changes")} */
const editToChanges = sync.require("../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")} */ /** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** /**
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
* @param {import("discord-api-types/v10").APIGuild} guild * @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) { async function editMessage(message, guild, row) {
const {roomID, eventsToRedact, eventsToReplace, eventsToSend, senderMxid, promotions} = await editToChanges.editToChanges(message, guild, api) 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. // 1. Replace all the things.
for (const {oldID, newContent} of eventsToReplace) { for (const {oldID, newContent} of eventsToReplace) {

View file

@ -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<string>} 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<string>} 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<string>} 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

View file

@ -123,7 +123,7 @@ async function memberToStateContent(user, member, guildID) {
return content return content
} }
function hashProfileContent(content) { function _hashProfileContent(content) {
const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}`) const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}`)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
return signedHash return signedHash
@ -142,7 +142,7 @@ function hashProfileContent(content) {
async function syncUser(user, member, guildID, roomID) { async function syncUser(user, member, guildID, roomID) {
const mxid = await ensureSimJoined(user, roomID) const mxid = await ensureSimJoined(user, roomID)
const content = await memberToStateContent(user, member, guildID) 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() 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 // only do the actual sync if the hash has changed since we last looked
if (existingHash !== currentHash) { if (existingHash !== currentHash) {
@ -179,6 +179,7 @@ async function syncAllUsersInRoom(roomID) {
} }
module.exports._memberToStateContent = memberToStateContent module.exports._memberToStateContent = memberToStateContent
module.exports._hashProfileContent = _hashProfileContent
module.exports.ensureSim = ensureSim module.exports.ensureSim = ensureSim
module.exports.ensureSimJoined = ensureSimJoined module.exports.ensureSimJoined = ensureSimJoined
module.exports.syncUser = syncUser module.exports.syncUser = syncUser

View file

@ -10,6 +10,8 @@ const messageToEvent = sync.require("../converters/message-to-event")
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("./register-user")} */ /** @type {import("./register-user")} */
const registerUser = sync.require("./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")} */ /** @type {import("../actions/create-room")} */
const createRoom = sync.require("../actions/create-room") const createRoom = sync.require("../actions/create-room")
/** @type {import("../../discord/utils")} */ /** @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").GatewayMessageCreateDispatchData} message
* @param {import("discord-api-types/v10").APIGuild} guild * @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) const roomID = await createRoom.ensureRoom(message.channel_id)
let senderMxid = null let senderMxid = null
@ -29,6 +32,13 @@ async function sendMessage(message, guild) {
} else { // well, good enough... } else { // well, good enough...
senderMxid = await registerUser.ensureSimJoined(message.author, roomID) 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}) const events = await messageToEvent.messageToEvent(message, guild, {}, {api})

View file

@ -22,8 +22,10 @@ async function updateCache(channelID, speedbumpID, speedbumpChecked) {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
if (speedbumpChecked && now - speedbumpChecked < SPEEDBUMP_UPDATE_FREQUENCY) return if (speedbumpChecked && now - speedbumpChecked < SPEEDBUMP_UPDATE_FREQUENCY) return
const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID) const webhooks = await discord.snow.webhook.getChannelWebhooks(channelID)
const found = webhooks.find(b => KNOWN_BOTS.has(b.application_id))?.application_id || null const found = webhooks.find(b => KNOWN_BOTS.has(b.application_id))
db.prepare("UPDATE channel_room SET speedbump_id = ?, speedbump_checked = ? WHERE channel_id = ?").run(found, now, channelID) 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<string>} set of messageID */ /** @type {Set<string>} set of messageID */

View file

@ -236,22 +236,22 @@ module.exports = {
*/ */
async onMessageCreate(client, message) { async onMessageCreate(client, message) {
if (message.author.username === "Deleted User") return // Nothing we can do for deleted users. 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) 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. 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) const guild = client.guilds.get(channel.guild_id)
assert(guild) 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) await discordCommandHandler.execute(message, channel, guild)
}, },
@ -260,13 +260,16 @@ module.exports = {
* @param {DiscordTypes.GatewayMessageUpdateDispatchData} data * @param {DiscordTypes.GatewayMessageUpdateDispatchData} data
*/ */
async onMessageUpdate(client, 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) { if (data.webhook_id) {
const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get() const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get()
if (row) { 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.
// The update 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) {
return // 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. // 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 the message content is a string then it includes all interesting fields and is meaningful.
if (typeof data.content === "string") { 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. 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) const guild = client.guilds.get(channel.guild_id)
assert(guild) assert(guild)
await editMessage.editMessage(message, guild) // @ts-ignore
await editMessage.editMessage(message, guild, row)
} }
}, },

View file

@ -1,6 +1,7 @@
BEGIN TRANSACTION; BEGIN TRANSACTION;
ALTER TABLE channel_room ADD COLUMN speedbump_id TEXT; 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; ALTER TABLE channel_room ADD COLUMN speedbump_checked INTEGER;
COMMIT; COMMIT;

1
db/orm-defs.d.ts vendored
View file

@ -8,6 +8,7 @@ export type Models = {
custom_avatar: string | null custom_avatar: string | null
last_bridged_pin_timestamp: number | null last_bridged_pin_timestamp: number | null
speedbump_id: string | null speedbump_id: string | null
speedbump_webhook_id: string | null
speedbump_checked: number | null speedbump_checked: number | null
} }

20
types.d.ts vendored
View file

@ -34,6 +34,26 @@ export type WebhookCreds = {
token: string 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 namespace Event {
export type Outer<T> = { export type Outer<T> = {
type: string type: string