From 08323f45129bef87bbc60805d0e4f64ff8c7d4dc Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 13 Feb 2026 21:59:17 +1300 Subject: [PATCH] More consistency for invite records table - Autojoined child spaces are recorded as invited - Update entry when reinvited - Delete entry when uninvited or removed from room - Allow linking with spaces you moderate, even if you didn't invite - Store power levels immediately for new invited rooms - Mark members as missing profile in this case - Only delete from invite table if it left the space --- ...33-add-missing-profile-to-member-cache.sql | 5 ++ src/db/orm-defs.d.ts | 3 +- src/m2d/converters/event-to-message.js | 6 +-- src/m2d/event-dispatcher.js | 50 +++++++++++++------ src/web/routes/guild.js | 8 ++- src/web/routes/link.js | 2 +- 6 files changed, 54 insertions(+), 20 deletions(-) create mode 100644 src/db/migrations/0033-add-missing-profile-to-member-cache.sql diff --git a/src/db/migrations/0033-add-missing-profile-to-member-cache.sql b/src/db/migrations/0033-add-missing-profile-to-member-cache.sql new file mode 100644 index 0000000..ef937e0 --- /dev/null +++ b/src/db/migrations/0033-add-missing-profile-to-member-cache.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +ALTER TABLE member_cache ADD COLUMN missing_profile INTEGER; + +COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index e36ed49..79f02ad 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -90,6 +90,7 @@ export type Models = { displayname: string | null avatar_url: string | null, power_level: number + missing_profile: number | null } member_power: { @@ -146,7 +147,7 @@ export type Models = { question_text: string is_closed: number } - + poll_option: { message_id: string matrix_option: string diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 9460ebb..ed8d2c3 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -290,8 +290,8 @@ function convertEmoji(mxcUrl, nameForGuess, allowSpriteSheetIndicator, allowLink * @returns {Promise<{displayname?: string?, avatar_url?: string?}>} */ async function getMemberFromCacheOrHomeserver(roomID, mxid, api) { - const row = select("member_cache", ["displayname", "avatar_url"], {room_id: roomID, mxid}).get() - if (row) return row + const row = select("member_cache", ["displayname", "avatar_url", "missing_profile"], {room_id: roomID, mxid}).get() + if (row && !row.missing_profile) return row return api.getStateEvent(roomID, "m.room.member", mxid).then(event => { const room = select("channel_room", "room_id", {room_id: roomID}).get() if (room) { @@ -299,7 +299,7 @@ async function getMemberFromCacheOrHomeserver(roomID, mxid, api) { // the cache will be kept in sync by the `m.room.member` event listener const displayname = event?.displayname || null const avatar_url = event?.avatar_url || null - db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?").run( + db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?, missing_profile = NULL").run( roomID, mxid, displayname, avatar_url, displayname, avatar_url diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 57c0fa6..70e293b 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -371,7 +371,18 @@ sync.addTemporaryListener(as, "type:m.space.child", guard("m.space.child", */ async event => { if (Array.isArray(event.content.via) && event.content.via.length) { // space child is being added - await api.joinRoom(event.state_key).catch(() => {}) // try to join if able, it's okay if it doesn't want, bot will still respond to invites + try { + // try to join if able, it's okay if it doesn't want, bot will still respond to invites + await api.joinRoom(event.state_key) + // if autojoined a child space, store it in invite (otherwise the child space will be impossible to use with self-service in the future) + const hierarchy = await api.getHierarchy(event.state_key, {limit: 1}) + const roomProperties = hierarchy.rooms?.[0] + if (roomProperties?.room_id === event.state_key && roomProperties.room_type === "m.space" && roomProperties.name) { + db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)") + .run(event.sender, event.state_key, roomProperties.room_type, roomProperties.name, roomProperties.topic, roomProperties.avatar_url) + await updateMemberCachePowerLevels(event.state_key) // store privileged users in member_cache so they are also allowed to perform self-service + } + } catch (e) {} } })) @@ -404,22 +415,24 @@ async event => { } if (!inviteRoomState?.name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite.`) await api.joinRoom(event.room_id) - db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar) + db.prepare("REPLACE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar) if (inviteRoomState.avatar) utils.getPublicUrlForMxc(inviteRoomState.avatar) // make sure it's available in the media_proxy allowed URLs + await updateMemberCachePowerLevels(event.room_id) // store privileged users in member_cache so they are also allowed to perform self-service } - if (utils.eventSenderIsFromDiscord(event.state_key)) return - if (event.content.membership === "leave" || event.content.membership === "ban") { // Member is gone db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) - // Unregister room's use as a direct chat if the bot itself left + // Unregister room's use as a direct chat and/or an invite target if the bot itself left if (event.state_key === utils.bot) { db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id) + db.prepare("DELETE FROM invite WHERE room_id = ?").run(event.room_id) } } + if (utils.eventSenderIsFromDiscord(event.state_key)) return + const exists = select("channel_room", "room_id", {room_id: event.room_id}) ?? select("guild_space", "space_id", {space_id: event.room_id}) if (!exists) return // don't cache members in unbridged rooms @@ -428,7 +441,7 @@ async event => { if (memberPower === Infinity) memberPower = tombstone // database storage compatibility const displayname = event.content.displayname || null const avatar_url = event.content.avatar_url - db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?, power_level = ?").run( + db.prepare("INSERT INTO member_cache (room_id, mxid, displayname, avatar_url, power_level) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET displayname = ?, avatar_url = ?, power_level = ?, missing_profile = NULL").run( event.room_id, event.state_key, displayname, avatar_url, memberPower, displayname, avatar_url, memberPower @@ -441,16 +454,25 @@ sync.addTemporaryListener(as, "type:m.room.power_levels", guard("m.room.power_le */ async event => { if (event.state_key !== "") return - const existingPower = select("member_cache", "mxid", {room_id: event.room_id}).pluck().all() - const {allCreators} = await utils.getEffectivePower(event.room_id, [], api) - const newPower = event.content.users || {} - for (const mxid of existingPower) { - if (!allCreators.includes(mxid)) { - db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(newPower[mxid] || 0, event.room_id, mxid) - } - } + await updateMemberCachePowerLevels(event.room_id) })) +/** + * @param {string} roomID + */ +async function updateMemberCachePowerLevels(roomID) { + const existingPower = select("member_cache", "mxid", {room_id: roomID}).pluck().all() + const {powerLevels, allCreators, tombstone} = await utils.getEffectivePower(roomID, [], api) + const newPower = powerLevels.users || {} + const newPowerUsers = Object.keys(newPower) + const relevantUsers = existingPower.concat(newPowerUsers).concat(allCreators) + for (const mxid of [...new Set(relevantUsers)]) { + const level = allCreators.includes(mxid) ? tombstone : newPower[mxid] ?? powerLevels.users_default ?? 0 + db.prepare("INSERT INTO member_cache (room_id, mxid, power_level, missing_profile) VALUES (?, ?, ?, 1) ON CONFLICT DO UPDATE SET power_level = ?") + .run(roomID, mxid, level, level) + } +} + sync.addTemporaryListener(as, "type:m.room.tombstone", guard("m.room.tombstone", /** * @param {Ty.Event.StateOuter} event diff --git a/src/web/routes/guild.js b/src/web/routes/guild.js index 4f140a3..5f9e2d9 100644 --- a/src/web/routes/guild.js +++ b/src/web/routes/guild.js @@ -148,7 +148,13 @@ as.router.get("/guild", defineEventHandler(async event => { // Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow if (!row.space_id && row.autocreate === 0) { - const spaces = db.prepare("SELECT room_id, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id WHERE mxid = ? AND space_id IS NULL AND type = 'm.space'").all(session.data.mxid) + let spaces = + // invited spaces + db.prepare("SELECT room_id, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id WHERE mxid = ? AND space_id IS NULL AND type = 'm.space'").all(session.data.mxid) + // moderated spaces + .concat(db.prepare("SELECT room_id, type, name, topic, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id INNER JOIN member_cache USING (room_id) WHERE member_cache.mxid = ? AND power_level >= 50 AND space_id IS NULL AND type = 'm.space'").all(session.data.mxid)) + const seen = new Set(spaces.map(s => s.room_id)) + spaces = spaces.filter(s => seen.delete(s.room_id)) return pugSync.render(event, "guild_not_linked.pug", {guild, guild_id, spaces}) } diff --git a/src/web/routes/link.js b/src/web/routes/link.js index 3ae6de5..248b4ef 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -282,11 +282,11 @@ as.router.post("/api/unlink-space", defineEventHandler(async event => { await utils.setUserPower(spaceID, utils.bot, 0, api) await api.leaveRoom(spaceID) db.prepare("DELETE FROM guild_space WHERE guild_id = ? AND space_id = ?").run(guild_id, spaceID) + db.prepare("DELETE FROM invite WHERE room_id = ?").run(spaceID) } // Mark as not considered for bridging db.prepare("DELETE FROM guild_active WHERE guild_id = ?").run(guild_id) - db.prepare("DELETE FROM invite WHERE room_id = ?").run(spaceID) await snow.user.leaveGuild(guild_id) setResponseHeader(event, "HX-Redirect", "/")