Support creating v12 rooms

This commit is contained in:
Cadence Ember
2025-12-16 02:15:17 +13:00
parent a6bb248c0a
commit e4d0838af5
10 changed files with 149 additions and 38 deletions

View File

@@ -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<Ty.Event.M_Room_Create>} */
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<string>} callback must return room ID
* @returns {Promise<string>} room ID
* @param {(_: any) => Promise<Ty.Event.StateOuter<Ty.Event.M_Room_Create>>} callback must return room ID and room version
* @returns {Promise<Ty.Event.StateOuter<Ty.Event.M_Room_Create>>} 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"}

View File

@@ -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": {

View File

@@ -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,

View File

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

View File

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

View File

@@ -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<Ty.Event.M_Room_Create>} 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

View File

@@ -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<Ty.Event.BaseStateEvent>} 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<Ty.Event.InviteStrippedState[]>}
@@ -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

View File

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

7
src/types.d.ts vendored
View File

@@ -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 {