From e4d0838af5a1fe351e28ea4e5e0b145f22ab0407 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Tue, 16 Dec 2025 02:15:17 +1300 Subject: [PATCH] Support creating v12 rooms --- src/d2m/actions/create-room.js | 54 +++++++++++++++++----------- src/d2m/actions/create-room.test.js | 6 ++-- src/d2m/actions/create-space.js | 20 +++++++---- src/d2m/actions/create-space.test.js | 6 +++- src/db/orm.test.js | 2 +- src/m2d/converters/utils.js | 47 +++++++++++++++++++++++- src/matrix/api.js | 11 ++++++ src/matrix/kstate.js | 31 +++++++++++++--- src/types.d.ts | 7 +++- test/data.js | 3 +- 10 files changed, 149 insertions(+), 38 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index f386e69..a1d9940 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -126,15 +126,21 @@ async function channelToKState(channel, guild, di) { const everyoneCanSend = dUtils.hasPermission(everyonePermissions, DiscordTypes.PermissionFlagsBits.SendMessages) const everyoneCanMentionEveryone = dUtils.hasAllPermissions(everyonePermissions, ["MentionEveryone"]) - const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all() - const globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {}) - /** @type {Ty.Event.M_Power_Levels} */ const spacePowerEvent = await di.api.getStateEvent(guildSpaceID, "m.room.power_levels", "") const spacePower = spacePowerEvent.users + const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all() + const globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {}) + const additionalCreators = select("member_power", "mxid", {room_id: "*"}, "AND power_level > 100").pluck().all() + + const creationContent = {} + creationContent.additional_creators = additionalCreators + if (channel.type === DiscordTypes.ChannelType.GuildForum) creationContent.type = "m.space" + /** @type {any} */ const channelKState = { + "m.room.create/": creationContent, "m.room.name/": {name: convertedName}, "m.room.topic/": {topic: convertedTopic}, "m.room.avatar/": avatarEventContent, @@ -193,7 +199,7 @@ async function channelToKState(channel, guild, di) { /** * Create a bridge room, store the relationship in the database, and add it to the guild's space. * @param {DiscordTypes.APIGuildTextChannel} channel - * @param guild + * @param {DiscordTypes.APIGuild} guild * @param {string} spaceID * @param {any} kstate * @param {number} privacyLevel @@ -203,9 +209,6 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) { let threadParent = null if (channel.type === DiscordTypes.ChannelType.PublicThread) threadParent = channel.parent_id - let spaceCreationContent = {} - if (channel.type === DiscordTypes.ChannelType.GuildForum) spaceCreationContent = {creation_content: {type: "m.space"}} - // Name and topic can be done earlier in room creation rather than in initial_state // https://spec.matrix.org/latest/client-server-api/#creation const name = kstate["m.room.name/"].name @@ -215,7 +218,7 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) { delete kstate["m.room.topic/"] assert(topic) - const roomID = await postApplyPowerLevels(kstate, async kstate => { + const roomCreate = await postApplyPowerLevels(kstate, async kstate => { const roomID = await api.createRoom({ name, topic, @@ -223,16 +226,20 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) { visibility: PRIVACY_ENUMS.VISIBILITY[privacyLevel], invite: [], initial_state: await ks.kstateToState(kstate), - ...spaceCreationContent + creation_content: ks.kstateToCreationContent(kstate) }) + /** @type {Ty.Event.StateOuter} */ + const roomCreate = await api.getStateEventOuter(roomID, "m.room.create", "") + db.transaction(() => { - db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES (?, ?, ?, NULL, ?)").run(channel.id, roomID, channel.name, threadParent) + db.prepare("INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent, guild_id) VALUES (?, ?, ?, NULL, ?, ?)").run(channel.id, roomID, channel.name, threadParent, guild.id) db.prepare("INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) VALUES (?, ?, 0)").run(channel.id, roomID) })() - return roomID + return roomCreate }) + const roomID = roomCreate.room_id // Put the newly created child into the space await _syncSpaceMember(channel, spaceID, roomID, guild.id) @@ -247,25 +254,30 @@ async function createRoom(channel, guild, spaceID, kstate, privacyLevel) { * https://github.com/matrix-org/synapse/blob/develop/synapse/handlers/room.py#L1170-L1210 * https://github.com/matrix-org/matrix-spec/issues/492 * @param {any} kstate - * @param {(_: any) => Promise} callback must return room ID - * @returns {Promise} room ID + * @param {(_: any) => Promise>} callback must return room ID and room version + * @returns {Promise>} room ID */ async function postApplyPowerLevels(kstate, callback) { const powerLevelContent = kstate["m.room.power_levels/"] const kstateWithoutPowerLevels = {...kstate} delete kstateWithoutPowerLevels["m.room.power_levels/"] - /** @type {string} */ - const roomID = await callback(kstateWithoutPowerLevels) + const roomCreate = await callback(kstateWithoutPowerLevels) + const roomID = roomCreate.room_id // Now *really* apply the power level overrides on top of what Synapse *really* set if (powerLevelContent) { - const newRoomKState = await ks.roomToKState(roomID) - const newRoomPowerLevelsDiff = ks.diffKState(newRoomKState, {"m.room.power_levels/": powerLevelContent}) - await ks.applyKStateDiffToRoom(roomID, newRoomPowerLevelsDiff) + mUtils.removeCreatorsFromPowerLevels(roomCreate, powerLevelContent) + + const originalPowerLevels = await api.getStateEvent(roomID, "m.room.power_levels", "") + const powerLevelsDiff = ks.diffKState( + {"m.room.power_levels/": originalPowerLevels}, + {"m.room.power_levels/": powerLevelContent} + ) + await ks.applyKStateDiffToRoom(roomID, powerLevelsDiff) } - return roomID + return roomCreate } /** @@ -392,8 +404,8 @@ async function _syncRoom(channelID, shouldActuallySync) { // sync channel state to room const roomKState = await ks.roomToKState(roomID) - if (+roomKState["m.room.create/"].room_version <= 8) { - // join_rule `restricted` is not available in room version < 8 and not working properly in version == 8 + if (!mUtils.roomHasAtLeastVersion(roomKState["m.room.create/"].room_version, 9)) { + // join_rule `restricted` is not available in room version < 8 and not working properly in version == 8, so require version 9 // read more: https://spec.matrix.org/v1.8/rooms/v9/ // we have to use `public` instead, otherwise the room will be unjoinable. channelKState["m.room.join_rules/"] = {join_rule: "public"} diff --git a/src/d2m/actions/create-room.test.js b/src/d2m/actions/create-room.test.js index e653744..ca09f73 100644 --- a/src/d2m/actions/create-room.test.js +++ b/src/d2m/actions/create-room.test.js @@ -124,7 +124,9 @@ test("channel2room: read-only discord channel", async t => { return {} } const expected = { - "chat.schildi.hide_ui/read_receipts": {}, + "m.room.create/": { + additional_creators: ["@test_auto_invite:example.org"], + }, "m.room.avatar/": { url: { $url: "/icons/112760669178241024/a_f83622e09ead74f0c5c527fe241f8f8c.png?size=1024", @@ -161,7 +163,7 @@ test("channel2room: read-only discord channel", async t => { room: 20, }, users: { - "@test_auto_invite:example.org": 100, + "@test_auto_invite:example.org": 150, }, }, "m.space.parent/!jjmvBegULiLucuWEHU:cadence.moe": { diff --git a/src/d2m/actions/create-space.js b/src/d2m/actions/create-space.js index 8bce3ad..1fb1911 100644 --- a/src/d2m/actions/create-space.js +++ b/src/d2m/actions/create-space.js @@ -35,8 +35,8 @@ async function createSpace(guild, kstate) { const enablePresenceByDefault = +(memberCount < 50) // scary! all active users in a presence-enabled guild will be pinging the server every <30 seconds to stay online const globalAdmins = select("member_power", "mxid", {room_id: "*"}).pluck().all() - const roomID = await createRoom.postApplyPowerLevels(kstate, async kstate => { - return api.createRoom({ + const roomCreate = await createRoom.postApplyPowerLevels(kstate, async kstate => { + const roomID = await api.createRoom({ name, preset: createRoom.PRIVACY_ENUMS.PRESET[createRoom.DEFAULT_PRIVACY_LEVEL], // New spaces will have to use the default privacy level; we obviously can't look up the existing entry visibility: createRoom.PRIVACY_ENUMS.VISIBILITY[createRoom.DEFAULT_PRIVACY_LEVEL], @@ -46,12 +46,14 @@ async function createSpace(guild, kstate) { }, invite: globalAdmins, topic, - creation_content: { - type: "m.space" - }, - initial_state: await ks.kstateToState(kstate) + initial_state: await ks.kstateToState(kstate), + creation_content: ks.kstateToCreationContent(kstate) }) + const roomCreate = await api.getStateEventOuter(roomID, "m.room.create", "") + return roomCreate }) + const roomID = roomCreate.room_id + db.prepare("INSERT INTO guild_space (guild_id, space_id, presence) VALUES (?, ?, ?)").run(guild.id, roomID, enablePresenceByDefault) return roomID } @@ -63,7 +65,13 @@ async function createSpace(guild, kstate) { async function guildToKState(guild, privacyLevel) { assert.equal(typeof privacyLevel, "number") const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all() + const additionalCreators = select("member_power", "mxid", {room_id: "*"}, "AND power_level > 100").pluck().all() + const guildKState = { + "m.room.create/": { + type: "m.space", + additional_creators: additionalCreators + }, "m.room.name/": {name: guild.name}, "m.room.avatar/": { $if: guild.icon, diff --git a/src/d2m/actions/create-space.test.js b/src/d2m/actions/create-space.test.js index cb4d90a..fc6eba4 100644 --- a/src/d2m/actions/create-space.test.js +++ b/src/d2m/actions/create-space.test.js @@ -13,6 +13,10 @@ test("guild2space: can generate kstate for a guild, passing privacy level 0", as t.deepEqual( await kstateUploadMxc(kstateStripConditionals(await guildToKState(testData.guild.general, 0))), { + "m.room.create/": { + additional_creators: ["@test_auto_invite:example.org"], + type: "m.space" + }, "m.room.avatar/": { url: "mxc://cadence.moe/zKXGZhmImMHuGQZWJEFKJbsF" }, @@ -30,7 +34,7 @@ test("guild2space: can generate kstate for a guild, passing privacy level 0", as }, "m.room.power_levels/": { users: { - "@test_auto_invite:example.org": 100 + "@test_auto_invite:example.org": 150 }, }, } diff --git a/src/db/orm.test.js b/src/db/orm.test.js index 56a3257..6f6018e 100644 --- a/src/db/orm.test.js +++ b/src/db/orm.test.js @@ -66,5 +66,5 @@ test("orm: select unsafe works (to select complex column names that can't be typ .and("where member_power.room_id = '*' and member_cache.power_level != member_power.power_level") .selectUnsafe("mxid", "member_cache.room_id", "member_power.power_level") .all() - t.equal(results[0].power_level, 100) + t.equal(results[0].power_level, 150) }) diff --git a/src/m2d/converters/utils.js b/src/m2d/converters/utils.js index 41cb0af..59035fe 100644 --- a/src/m2d/converters/utils.js +++ b/src/m2d/converters/utils.js @@ -1,7 +1,7 @@ // @ts-check const assert = require("assert").strict - +const Ty = require("../../types") const passthrough = require("../../passthrough") const {db} = passthrough @@ -232,6 +232,49 @@ function getPublicUrlForMxc(mxc) { return `${reg.ooye.bridge_origin}/download/matrix/${serverAndMediaID}` } +/** + * @param {string} roomVersionString + * @param {number} desiredVersion + */ +function roomHasAtLeastVersion(roomVersionString, desiredVersion) { + /* + I hate this. + The spec instructs me to compare room versions ordinally, for example, "In room versions 12 and higher..." + So if the real room version is 13, this should pass the check. + However, the spec also says "room versions are not intended to be parsed and should be treated as opaque identifiers", "due to versions not being ordered or hierarchical". + So versions are unordered and opaque and you can't parse them, but you're still expected to parse them to a number and compare them to another number to measure if it's "12 or higher"? + Theoretically MSC3244 would clean this up, but that isn't happening since Element removed support for MSC3244: https://github.com/element-hq/element-web/commit/644b8415912afb9c5eed54859a444a2ee7224117 + Element replaced it with the following function: + */ + + // Assumption: all unstable room versions don't support the feature. Calling code can check for unstable + // room versions explicitly if it wants to. The spec reserves [0-9] and `.` for its room versions. + if (!roomVersionString.match(/^[\d.]+$/)) { + return false; + } + + // Element dev note: While the spec says room versions are not linear, we can make reasonable assumptions + // until the room versions prove themselves to be non-linear in the spec. We should see this coming + // from a mile away and can course-correct this function if needed. + return Number(roomVersionString) >= Number(desiredVersion); +} + +/** + * Starting in room version 12, creators may not be specified in power levels users. + * Modifies the input power levels. + * @param {Ty.Event.StateOuter} roomCreateOuter + * @param {Ty.Event.M_Power_Levels} powerLevels + */ +function removeCreatorsFromPowerLevels(roomCreateOuter, powerLevels) { + assert(roomCreateOuter.sender) + if (roomHasAtLeastVersion(roomCreateOuter.content.room_version, 12)) { + for (const creator of (roomCreateOuter.content.additional_creators ?? []).concat(roomCreateOuter.sender)) { + delete powerLevels.users[creator] + } + } + return powerLevels +} + module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord module.exports.getPublicUrlForMxc = getPublicUrlForMxc @@ -239,3 +282,5 @@ module.exports.getEventIDHash = getEventIDHash module.exports.MatrixStringBuilder = MatrixStringBuilder module.exports.getViaServers = getViaServers module.exports.getViaServersQuery = getViaServersQuery +module.exports.roomHasAtLeastVersion = roomHasAtLeastVersion +module.exports.removeCreatorsFromPowerLevels = removeCreatorsFromPowerLevels diff --git a/src/matrix/api.js b/src/matrix/api.js index d2a0e9d..c17d789 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -138,6 +138,16 @@ function getStateEvent(roomID, type, key) { return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}`) } +/** + * @param {string} roomID + * @param {string} type + * @param {string} key + * @returns {Promise} the entire state event + */ +function getStateEventOuter(roomID, type, key) { + return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}?format=event`) +} + /** * @param {string} roomID * @returns {Promise} @@ -554,6 +564,7 @@ module.exports.getEvent = getEvent module.exports.getEventForTimestamp = getEventForTimestamp module.exports.getAllState = getAllState module.exports.getStateEvent = getStateEvent +module.exports.getStateEventOuter = getStateEventOuter module.exports.getInviteState = getInviteState module.exports.getJoinedMembers = getJoinedMembers module.exports.getMembers = getMembers diff --git a/src/matrix/kstate.js b/src/matrix/kstate.js index 11155f5..d131889 100644 --- a/src/matrix/kstate.js +++ b/src/matrix/kstate.js @@ -10,6 +10,8 @@ const {sync} = passthrough const file = sync.require("./file") /** @type {import("./api")} */ const api = sync.require("./api") +/** @type {import("../m2d/converters/utils")} */ +const utils = sync.require("../m2d/converters/utils") /** Mutates the input. Not recursive - can only include or exclude entire state events. */ function kstateStripConditionals(kstate) { @@ -45,12 +47,13 @@ async function kstateUploadMxc(obj) { return obj } -/** Automatically strips conditionals and uploads URLs to mxc. */ +/** Automatically strips conditionals and uploads URLs to mxc. m.room.create is removed. */ async function kstateToState(kstate) { const events = [] kstateStripConditionals(kstate) await kstateUploadMxc(kstate) for (const [k, content] of Object.entries(kstate)) { + if (k === "m.room.create/") continue const slashIndex = k.indexOf("/") assert(slashIndex > 0) const type = k.slice(0, slashIndex) @@ -60,6 +63,11 @@ async function kstateToState(kstate) { return events } +/** Extracts m.room.create for use in room creation_content. */ +function kstateToCreationContent(kstate) { + return kstate["m.room.create/"] || {} +} + /** * @param {import("../types").Event.BaseStateEvent[]} events * @returns {any} @@ -68,6 +76,11 @@ function stateToKState(events) { const kstate = {} for (const event of events) { kstate[event.type + "/" + event.state_key] = event.content + + // need to remember m.room.create sender for later... + if (event.type === "m.room.create" && event.state_key === "") { + kstate["m.room.create/outer"] = event + } } return kstate } @@ -81,12 +94,21 @@ function diffKState(actual, target) { if (key === "m.room.power_levels/") { // Special handling for power levels, we want to deep merge the actual and target into the final state. if (!(key in actual)) throw new Error(`want to apply a power levels diff, but original power level data is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`) - const temp = mixin({}, actual[key], target[key]) - if (!isDeepStrictEqual(actual[key], temp)) { + const mixedTarget = mixin({}, actual[key], target[key]) + if (!isDeepStrictEqual(actual[key], mixedTarget)) { // they differ. use the newly prepared object as the diff. - diff[key] = temp + // if the diff includes users, it needs to be cleaned wrt room version 12 + if (target[key].users && Object.keys(target[key].users).length > 0) { + if (!("m.room.create/" in actual)) throw new Error(`want to apply a power levels diff, but original m.room.create/ is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`) + if (!("m.room.create/outer" in actual)) throw new Error(`want to apply a power levels diff, but original m.room.create/outer is missing\nstarted with: ${JSON.stringify(actual)}\nwant to apply: ${JSON.stringify(target)}`) + utils.removeCreatorsFromPowerLevels(actual["m.room.create/outer"], mixedTarget) + } + diff[key] = mixedTarget } + } else if (key === "m.room.create/") { + // can't be modified - only for kstateToCreationContent + } else if (key in actual) { // diff if (!isDeepStrictEqual(actual[key], target[key])) { @@ -129,6 +151,7 @@ async function applyKStateDiffToRoom(roomID, kstate) { module.exports.kstateStripConditionals = kstateStripConditionals module.exports.kstateUploadMxc = kstateUploadMxc module.exports.kstateToState = kstateToState +module.exports.kstateToCreationContent = kstateToCreationContent module.exports.stateToKState = stateToKState module.exports.diffKState = diffKState module.exports.roomToKState = roomToKState diff --git a/src/types.d.ts b/src/types.d.ts index 8b8bae9..3b0e5af 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -174,7 +174,7 @@ export namespace Event { } export type M_Room_Create = { - additional_creators: string[] + additional_creators?: string[] "m.federate"?: boolean room_version: string type?: string @@ -356,6 +356,11 @@ export namespace Event { }> & { redacts: string } + + export type M_Room_Tombstone = { + body: string + replacement_room: string + } } export namespace R { diff --git a/test/data.js b/test/data.js index b9e9da4..387ad6a 100644 --- a/test/data.js +++ b/test/data.js @@ -101,6 +101,7 @@ module.exports = { }, room: { general: { + "m.room.create/": {additional_creators: ["@test_auto_invite:example.org"]}, "m.room.name/": {name: "main"}, "m.room.topic/": {topic: "#collective-unconscious | https://docs.google.com/document/d/blah/edit | I spread, pipe, and whip because it is my will. :headstone:\n\nChannel ID: 112760669178241024\nGuild ID: 112760669178241024"}, "m.room.guest_access/": {guest_access: "can_join"}, @@ -126,7 +127,7 @@ module.exports = { "m.room.redaction": 0 }, users: { - "@test_auto_invite:example.org": 100 + "@test_auto_invite:example.org": 150 }, notifications: { room: 0