diff --git a/scripts/backfill.js b/scripts/backfill.js index 27600f0..c0c440e 100644 --- a/scripts/backfill.js +++ b/scripts/backfill.js @@ -38,12 +38,8 @@ passthrough.select = orm.select /** @type {import("../src/d2m/event-dispatcher")}*/ const eventDispatcher = sync.require("../src/d2m/event-dispatcher") - -const roomID = passthrough.select("channel_room", "room_id", {channel_id: channelID}).pluck().get() -if (!roomID) { - console.error("Please choose a channel that's already bridged.") - process.exit(1) -} +/** @type {import("../src/d2m/actions/create-room")} */ +const createRoom = sync.require("../src/d2m/actions/create-room") ;(async () => { await discord.cloud.connect() @@ -60,6 +56,18 @@ async function event(event) { if (!channel) return const guild_id = event.d.id + let roomID = passthrough.select("channel_room", "room_id", {channel_id: channelID}).pluck().get() + if (!roomID) { + console.log(`Channel #${channel.name} is not bridged yet. Attempting to auto-create...`) + try { + roomID = await createRoom.syncRoom(channelID) + console.log(`Successfully bridged to new room: ${roomID}`) + } catch (e) { + console.error(`Failed to auto-create room: ${e.message}`) + process.exit(1) + } + } + let last = backfill.prepare("SELECT cast(max(message_id) as TEXT) FROM backfill WHERE channel_id = ?").pluck().get(channelID) || "0" console.log(`OK, processing messages for #${channel.name}, continuing from ${last}`) diff --git a/src/d2m/actions/remove-member.js b/src/d2m/actions/remove-member.js new file mode 100644 index 0000000..f4d18cf --- /dev/null +++ b/src/d2m/actions/remove-member.js @@ -0,0 +1,124 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") + +const passthrough = require("../../passthrough") +const {discord, sync, db, select, from} = passthrough +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") + +/** + * Make a specific sim leave all rooms in a guild and clean up the database. + * @param {string} mxid + * @param {string} guildID + * @returns {Promise} number of rooms left + */ +async function removeSimFromGuild(mxid, guildID) { + const rooms = from("sim_member") + .join("channel_room", "room_id") + .where({mxid, guild_id: guildID}) + .pluck("room_id") + .all() + + for (const roomID of rooms) { + try { + await api.leaveRoom(roomID, mxid) + } catch (e) { + // Room may no longer exist or sim may already have left + console.log(`[remove member] failed to leave room ${roomID}: ${e}`) + } + db.prepare("DELETE FROM sim_member WHERE mxid = ? AND room_id = ?").run(mxid, roomID) + } + + return rooms.length +} + +/** + * Remove a user's sim and their PluralKit proxy sims from all rooms in a guild. + * Called when a Discord user leaves a guild. + * @param {string} userID Discord user ID + * @param {string} guildID Discord guild ID + */ +async function removeMember(userID, guildID) { + // Remove the user's own sim + const mxid = select("sim", "mxid", {user_id: userID}).pluck().get() + if (mxid) { + const count = await removeSimFromGuild(mxid, guildID) + if (count) console.log(`[remove member] removed sim for ${userID} from ${count} rooms in guild ${guildID}`) + } + + // Remove PluralKit proxy sims owned by this user + const pkUserIDs = select("sim_proxy", "user_id", {proxy_owner_id: userID}).pluck().all() + for (const pkUserID of pkUserIDs) { + const pkMxid = select("sim", "mxid", {user_id: pkUserID}).pluck().get() + if (!pkMxid) continue + const count = await removeSimFromGuild(pkMxid, guildID) + if (count) console.log(`[remove member] removed pk sim for ${pkUserID} (owner: ${userID}) from ${count} rooms in guild ${guildID}`) + } +} + +/** + * Backfill: check all sims in guild rooms and remove those whose Discord users have left the guild. + * Called on GUILD_CREATE to catch removals that happened while the bridge was offline. + * @param {DiscordTypes.GatewayGuildCreateDispatchData} guild + */ +async function checkMissedMembers(guild) { + if (guild.unavailable) return + + // Find all distinct regular Discord user IDs with sims in this guild's rooms + // Exclude PK sims (UUIDs with dashes) and webhook sims (with underscores) + const rows = from("sim_member") + .join("channel_room", "room_id") + .join("sim", "mxid") + .where({guild_id: guild.id}) + .and("AND user_id NOT LIKE '%-%' AND user_id NOT LIKE '%\\_%' ESCAPE '\\'") + .pluck("user_id") + .all() + + const userIDs = [...new Set(rows)] + + for (const userID of userIDs) { + try { + await discord.snow.guild.getGuildMember(guild.id, userID) + } catch (e) { + if (String(e).includes("10007") || String(e).includes("Unknown Member")) { + console.log(`[remove member] backfill: user ${userID} is no longer in guild ${guild.id}`) + await removeMember(userID, guild.id) + } + // Other errors (rate limits, network issues) - skip this user + } + } + + // Also check PK proxy owners who may not have a regular sim but whose PK sims are in guild rooms + const pkRows = from("sim_member") + .join("channel_room", "room_id") + .join("sim", "mxid") + .join("sim_proxy", "user_id") + .where({guild_id: guild.id}) + .pluck("proxy_owner_id") + .all() + + const pkOwnerIDs = [...new Set(pkRows)].filter(id => !userIDs.includes(id)) + + for (const ownerID of pkOwnerIDs) { + try { + await discord.snow.guild.getGuildMember(guild.id, ownerID) + } catch (e) { + if (String(e).includes("10007") || String(e).includes("Unknown Member")) { + console.log(`[remove member] backfill: pk owner ${ownerID} is no longer in guild ${guild.id}`) + // Only remove PK sims for this owner, not their own sim (they don't have one in this guild) + const pkUserIDs = select("sim_proxy", "user_id", {proxy_owner_id: ownerID}).pluck().all() + for (const pkUserID of pkUserIDs) { + const pkMxid = select("sim", "mxid", {user_id: pkUserID}).pluck().get() + if (!pkMxid) continue + const count = await removeSimFromGuild(pkMxid, guild.id) + if (count) console.log(`[remove member] backfill: removed pk sim for ${pkUserID} (owner: ${ownerID}) from ${count} rooms in guild ${guild.id}`) + } + } + } + } +} + +module.exports.removeSimFromGuild = removeSimFromGuild +module.exports.removeMember = removeMember +module.exports.checkMissedMembers = checkMissedMembers diff --git a/src/d2m/actions/remove-member.test.js b/src/d2m/actions/remove-member.test.js new file mode 100644 index 0000000..383ba7d --- /dev/null +++ b/src/d2m/actions/remove-member.test.js @@ -0,0 +1,202 @@ +// @ts-check + +const {test} = require("supertape") +const {removeSimFromGuild, removeMember, checkMissedMembers} = require("./remove-member") +const passthrough = require("../../passthrough") +const {db, sync, discord} = passthrough + +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") + +function setupTestSim(userID, simName, mxid) { + db.prepare("INSERT OR IGNORE INTO sim (user_id, username, sim_name, mxid) VALUES (?, ?, ?, ?)").run(userID, simName, simName, mxid) +} + +function setupTestSimMember(mxid, roomID) { + db.prepare("INSERT OR IGNORE INTO sim_member (mxid, room_id, hashed_profile_content) VALUES (?, ?, NULL)").run(mxid, roomID) +} + +function cleanupTestSim(userID) { + const mxid = db.prepare("SELECT mxid FROM sim WHERE user_id = ?").pluck().get(userID) + if (mxid) { + db.prepare("DELETE FROM sim_member WHERE mxid = ?").run(mxid) + } + db.prepare("DELETE FROM sim WHERE user_id = ?").run(userID) +} + +test("remove-member: removeSimFromGuild removes sim from all guild rooms", async t => { + const mxid = "@_ooye_testrm1:cadence.moe" + const guildID = "112760669178241024" + const roomID1 = "!kLRqKKUQXcibIMtOpl:cadence.moe" + const roomID2 = "!fGgIymcYWOqjbSRUdV:cadence.moe" + + setupTestSim("999999990", "testrm1", mxid) + setupTestSimMember(mxid, roomID1) + setupTestSimMember(mxid, roomID2) + + const leftRooms = [] + const originalLeaveRoom = api.leaveRoom + api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) } + + try { + const count = await removeSimFromGuild(mxid, guildID) + + t.equal(count, 2) + t.equal(leftRooms.length, 2) + t.ok(leftRooms.some(r => r.roomID === roomID1)) + t.ok(leftRooms.some(r => r.roomID === roomID2)) + + const remaining = db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ?").pluck().get(mxid) + t.equal(remaining, 0) + } finally { + api.leaveRoom = originalLeaveRoom + cleanupTestSim("999999990") + } +}) + +test("remove-member: removeSimFromGuild only affects rooms in the specified guild", async t => { + const mxid = "@_ooye_testrm2:cadence.moe" + const guildID = "112760669178241024" + const guildRoom = "!kLRqKKUQXcibIMtOpl:cadence.moe" // guild 112760669178241024 + const otherGuildRoom = "!BnKuBPCvyfOkhcUjEu:cadence.moe" // guild 66192955777486848 + + setupTestSim("999999991", "testrm2", mxid) + setupTestSimMember(mxid, guildRoom) + setupTestSimMember(mxid, otherGuildRoom) + + const leftRooms = [] + const originalLeaveRoom = api.leaveRoom + api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) } + + try { + const count = await removeSimFromGuild(mxid, guildID) + + t.equal(count, 1) + t.equal(leftRooms[0].roomID, guildRoom) + + const otherRemaining = db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ? AND room_id = ?").pluck().get(mxid, otherGuildRoom) + t.equal(otherRemaining, 1, "other guild's room should be untouched") + } finally { + api.leaveRoom = originalLeaveRoom + cleanupTestSim("999999991") + } +}) + +test("remove-member: removeSimFromGuild handles leaveRoom errors gracefully", async t => { + const mxid = "@_ooye_testrm3:cadence.moe" + const guildID = "112760669178241024" + const roomID = "!kLRqKKUQXcibIMtOpl:cadence.moe" + + setupTestSim("999999992", "testrm3", mxid) + setupTestSimMember(mxid, roomID) + + const originalLeaveRoom = api.leaveRoom + api.leaveRoom = async () => { throw new Error("not in room") } + + try { + const count = await removeSimFromGuild(mxid, guildID) + t.equal(count, 1) + + const remaining = db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ?").pluck().get(mxid) + t.equal(remaining, 0, "sim_member should be deleted even if leaveRoom fails") + } finally { + api.leaveRoom = originalLeaveRoom + cleanupTestSim("999999992") + } +}) + +test("remove-member: removeMember removes user sim and their PK proxy sims", async t => { + const userID = "999999993" + const mxid = "@_ooye_testrm4:cadence.moe" + const pkUserID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + const pkMxid = "@_ooye__pk_testrmpk:cadence.moe" + const guildID = "112760669178241024" + const roomID = "!kLRqKKUQXcibIMtOpl:cadence.moe" + + setupTestSim(userID, "testrm4", mxid) + setupTestSimMember(mxid, roomID) + setupTestSim(pkUserID, "_pk_testrmpk", pkMxid) + setupTestSimMember(pkMxid, roomID) + db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkUserID, userID, "Test PK") + + const leftRooms = [] + const originalLeaveRoom = api.leaveRoom + api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) } + + try { + await removeMember(userID, guildID) + + t.equal(leftRooms.length, 2) + t.ok(leftRooms.some(r => r.mxid === mxid), "user sim should leave") + t.ok(leftRooms.some(r => r.mxid === pkMxid), "pk sim should leave") + + t.equal(db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ?").pluck().get(mxid), 0) + t.equal(db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ?").pluck().get(pkMxid), 0) + } finally { + api.leaveRoom = originalLeaveRoom + db.prepare("DELETE FROM sim_proxy WHERE user_id = ?").run(pkUserID) + cleanupTestSim(userID) + cleanupTestSim(pkUserID) + } +}) + +test("remove-member: removeMember does nothing for unknown user", async t => { + const leftRooms = [] + const originalLeaveRoom = api.leaveRoom + api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) } + + try { + await removeMember("000000000000000000", "112760669178241024") + t.equal(leftRooms.length, 0) + } finally { + api.leaveRoom = originalLeaveRoom + } +}) + +test("remove-member: checkMissedMembers removes sims for departed users", async t => { + const userID = "999999994" + const mxid = "@_ooye_testrm5:cadence.moe" + const guildID = "112760669178241024" + const roomID = "!kLRqKKUQXcibIMtOpl:cadence.moe" + + setupTestSim(userID, "testrm5", mxid) + setupTestSimMember(mxid, roomID) + + const leftRooms = [] + const originalLeaveRoom = api.leaveRoom + api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) } + + const originalSnow = discord.snow + discord.snow = { + guild: { + getGuildMember: async (gid, uid) => { + if (uid === userID) throw new Error('{"message": "Unknown Member", "code": 10007}') + return {} + } + } + } + + try { + await checkMissedMembers({id: guildID}) + + t.ok(leftRooms.some(r => r.mxid === mxid), "departed user's sim should be removed") + t.equal(db.prepare("SELECT COUNT(*) FROM sim_member WHERE mxid = ?").pluck().get(mxid), 0) + } finally { + api.leaveRoom = originalLeaveRoom + discord.snow = originalSnow + cleanupTestSim(userID) + } +}) + +test("remove-member: checkMissedMembers skips unavailable guilds", async t => { + const leftRooms = [] + const originalLeaveRoom = api.leaveRoom + api.leaveRoom = async (roomID, userMxid) => { leftRooms.push({roomID, mxid: userMxid}) } + + try { + await checkMissedMembers({id: "112760669178241024", unavailable: true}) + t.equal(leftRooms.length, 0, "should not process unavailable guilds") + } finally { + api.leaveRoom = originalLeaveRoom + } +}) diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 7f77b81..4ec28c2 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -769,7 +769,21 @@ async function messageToEvent(message, guild, options = {}, di) { // Then scheduled events if (message.content && di?.snow) { for (const match of [...message.content.matchAll(/discord\.gg\/([A-Za-z0-9]+)\?event=([0-9]{18,})/g)]) { // snowflake has minimum 18 because the events feature is at least that old - const invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) + let invite + try { + invite = await di.snow.invite.getInvite(match[1], {guild_scheduled_event_id: match[2]}) + } catch (e) { + // Skip expired events and invites + if (e.code === 10006 || e.httpStatus === 404) { + console.warn(`[Backfill] Skipped expired scheduled event: ${match[0]}`) + const fallbackBody = `[Expired Scheduled Event: ${match[0]}]` + const fallbackHtml = `
Expired Scheduled Event: ${match[0]}
` + await addTextEvent(fallbackBody, fallbackHtml, "m.notice") + continue + } + throw e + } + const event = invite.guild_scheduled_event if (!event) continue // the event ID provided was not valid diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 1a73aea..166c3d7 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1538,6 +1538,38 @@ test("message2event: vc invite event renders embed with room link", async t => { ]) }) +test("message2event: expired event invite renders fallback notice", async t => { + const events = await messageToEvent({content: "https://discord.gg/placeholder?event=1381190945646710824"}, {}, {}, { + snow: { + invite: { + getInvite: async () => { + const error = new Error("Unknown Invite") + error.code = 10006 + throw error + } + } + } + }) + t.deepEqual(events, [ + { + $type: "m.room.message", + body: "https://discord.gg/placeholder?event=1381190945646710824", + format: "org.matrix.custom.html", + formatted_body: "https://discord.gg/placeholder?event=1381190945646710824", + "m.mentions": {}, + msgtype: "m.text", + }, + { + $type: "m.room.message", + msgtype: "m.notice", + body: "[Expired Scheduled Event: discord.gg/placeholder?event=1381190945646710824]", + format: "org.matrix.custom.html", + formatted_body: "
Expired Scheduled Event: discord.gg/placeholder?event=1381190945646710824
", + "m.mentions": {} + } + ]) +}) + test("message2event: channel links are converted even inside lists (parser post-processer descends into list items)", async t => { let called = 0 const events = await messageToEvent({ diff --git a/src/d2m/discord-client.js b/src/d2m/discord-client.js index 7b0fcf8..4b3719c 100644 --- a/src/d2m/discord-client.js +++ b/src/d2m/discord-client.js @@ -23,7 +23,7 @@ class DiscordClient { /** @type {import("cloudstorm").IClientOptions["intents"]} */ const intents = [ "DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING", - "GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS", + "GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MEMBERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS", "MESSAGE_CONTENT" ] if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES") diff --git a/src/d2m/discord-packets.js b/src/d2m/discord-packets.js index 8cf2fde..60ed3ac 100644 --- a/src/d2m/discord-packets.js +++ b/src/d2m/discord-packets.js @@ -51,6 +51,7 @@ const utils = { await eventDispatcher.checkMissedExpressions(message.d) await eventDispatcher.checkMissedPins(client, message.d) await eventDispatcher.checkMissedMessages(client, message.d) + await eventDispatcher.checkMissedMembers(message.d) } catch (e) { console.error("Failed to sync missed events. To retry, please fix this error and restart OOYE:") console.error(e) diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 01bbc67..8194ab1 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -38,6 +38,8 @@ const vote = sync.require("./actions/poll-vote") const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") /** @type {import("../discord/interactions/matrix-info")} */ const matrixInfoInteraction = sync.require("../discord/interactions/matrix-info") +/** @type {import("./actions/remove-member")} */ +const removeMember = sync.require("./actions/remove-member") const {Semaphore} = require("@chriscdn/promise-semaphore") const checkMissedPinsSema = new Semaphore() @@ -413,5 +415,24 @@ module.exports = { const status = data.status if (!status) return setPresence.presenceTracker.incomingPresence(data.user.id, data.guild_id, status) + }, + + /** + * When a Discord user leaves (or is kicked/banned from) the guild, make their sims leave the bridged Matrix rooms. + * Also removes PluralKit proxy sims owned by the departing user. + * @param {import("./discord-client")} client + * @param {DiscordTypes.GatewayGuildMemberRemoveDispatchData} data + */ + async GUILD_MEMBER_REMOVE(client, data) { + if (data.user.id === client.user.id) return // Don't process if the bot itself is removed + await removeMember.removeMember(data.user.id, data.guild_id) + }, + + /** + * When logging back in, check for sims whose Discord users have left the guild while the bridge was offline. + * @param {DiscordTypes.GatewayGuildCreateDispatchData} guild + */ + async checkMissedMembers(guild) { + await removeMember.checkMissedMembers(guild) } } diff --git a/test/test.js b/test/test.js index e05b687..6094e47 100644 --- a/test/test.js +++ b/test/test.js @@ -13,7 +13,10 @@ const {green} = require("ansi-colors") const passthrough = require("../src/passthrough") const db = new sqlite(":memory:") -const {reg} = require("../src/matrix/read-registration") +const readReg = require("../src/matrix/read-registration") +readReg.reg = readReg.getTemplateRegistration("cadence.moe") +const {reg} = readReg +reg.url = "http://localhost:6693" reg.ooye.discord_token = "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby" reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded reg.ooye.server_name = "cadence.moe" @@ -145,6 +148,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not require("../src/d2m/actions/create-room.test") require("../src/d2m/actions/create-space.test") require("../src/d2m/actions/register-user.test") + require("../src/d2m/actions/remove-member.test") require("../src/d2m/converters/edit-to-changes.test") require("../src/d2m/converters/emoji-to-key.test") require("../src/d2m/converters/find-mentions.test")