d->m: Make PK members appear real
This commit is contained in:
		| @@ -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) { | ||||
|   | ||||
							
								
								
									
										139
									
								
								d2m/actions/register-pk-user.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								d2m/actions/register-pk-user.js
									
									
									
									
									
										Normal 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 | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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}) | ||||
|   | ||||
| @@ -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<string>} set of messageID */ | ||||
|   | ||||
| @@ -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) | ||||
| 		} | ||||
| 	}, | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
							
								
								
									
										1
									
								
								db/orm-defs.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								db/orm-defs.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
| 	} | ||||
|  | ||||
|   | ||||
							
								
								
									
										20
									
								
								types.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										20
									
								
								types.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -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<T> = { | ||||
| 		type: string | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Cadence Ember
					Cadence Ember