Support creating v12 rooms
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
7
src/types.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user