Room version 12 and room upgrades

This commit is contained in:
Cadence Ember
2026-01-07 02:43:20 +13:00
parent 092a4cf7b0
commit 55e0e5dfa1
27 changed files with 666 additions and 483 deletions

View File

@@ -447,9 +447,8 @@ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) {
let writtenMentionMatch = content.match(/(?:^|[^"[<>/A-Za-z0-9])@([A-Za-z][A-Za-z0-9._\[\]\(\)-]+):?/d) // /d flag for indices requires node.js 16+
if (writtenMentionMatch) {
if (writtenMentionMatch[1] === "room") { // convert @room to @everyone
const powerLevels = await di.api.getStateEvent(roomID, "m.room.power_levels", "")
const userPower = powerLevels.users?.[senderMxid] || 0
if (userPower >= powerLevels.notifications?.room) {
const {powers: {[senderMxid]: userPower}, powerLevels} = await mxUtils.getEffectivePower(roomID, [senderMxid], di.api)
if (userPower >= (powerLevels.notifications?.room ?? 50)) {
return {
// @ts-ignore - typescript doesn't know about indices yet
content: content.slice(0, writtenMentionMatch.indices[1][0]-1) + `@everyone` + content.slice(writtenMentionMatch.indices[1][1]),
@@ -924,7 +923,6 @@ async function eventToMessage(event, guild, di) {
// Respect sender's angle brackets
const alreadySuppressed = content[match.index-1+offset] === "<" && content[match.index+match.length+offset] === ">"
console.error(content, match.index-1+offset, content[match.index-1+offset])
if (alreadySuppressed) continue
// Put < > around any surviving matrix.to links
let shouldSuppress = !!match[0].match(/^https?:\/\/matrix\.to\//)

View File

@@ -4985,7 +4985,7 @@ test("event2message: @room converts to @everyone and is allowed when the room do
event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A"
}, data.guild.general, {
api: {
getStateEvent(roomID, type, key) {
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(type, "m.room.power_levels")
@@ -4996,6 +4996,19 @@ test("event2message: @room converts to @everyone and is allowed when the room do
room: 0
}
}
},
async getStateEventOuter(roomID, type, key) {
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(type, "m.room.create")
t.equal(key, "")
return {
type: "m.room.create",
state_key: "",
sender: "@_ooye_bot:cadence.moe",
content: {
room_version: "11"
}
}
}
}
}),
@@ -5016,7 +5029,6 @@ test("event2message: @room converts to @everyone and is allowed when the room do
})
test("event2message: @room converts to @everyone but is not allowed when the room restricts who can use it", async t => {
let called = 0
t.deepEqual(
await eventToMessage({
type: "m.room.message",
@@ -5031,8 +5043,7 @@ test("event2message: @room converts to @everyone but is not allowed when the roo
event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A"
}, data.guild.general, {
api: {
getStateEvent(roomID, type, key) {
called++
async getStateEvent(roomID, type, key) {
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
@@ -5042,6 +5053,19 @@ test("event2message: @room converts to @everyone but is not allowed when the roo
room: 20
}
}
},
async getStateEventOuter(roomID, type, key) {
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(type, "m.room.create")
t.equal(key, "")
return {
type: "m.room.create",
state_key: "",
sender: "@_ooye_bot:cadence.moe",
content: {
room_version: "11"
}
}
}
}
}),
@@ -5062,7 +5086,6 @@ test("event2message: @room converts to @everyone but is not allowed when the roo
})
test("event2message: @room converts to @everyone and is allowed if the user has sufficient power to use it", async t => {
let called = 0
t.deepEqual(
await eventToMessage({
type: "m.room.message",
@@ -5077,8 +5100,7 @@ test("event2message: @room converts to @everyone and is allowed if the user has
event_id: "$SiXetU9h9Dg-M9Frcw_C6ahnoXZ3QPZe3MVJR5tcB9A"
}, data.guild.general, {
api: {
getStateEvent(roomID, type, key) {
called++
async getStateEvent(roomID, type, key) {
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
@@ -5090,6 +5112,19 @@ test("event2message: @room converts to @everyone and is allowed if the user has
room: 20
}
}
},
async getStateEventOuter(roomID, type, key) {
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe")
t.equal(type, "m.room.create")
t.equal(key, "")
return {
type: "m.room.create",
state_key: "",
sender: "@_ooye_bot:cadence.moe",
content: {
room_version: "11"
}
}
}
}
}),

View File

@@ -129,7 +129,7 @@ class MatrixStringBuilder {
* https://spec.matrix.org/v1.9/appendices/#routing
* https://gitdab.com/cadence/out-of-your-element/issues/11
* @param {string} roomID
* @param {{[K in "getStateEvent" | "getStateEventOuter" | "getJoinedMembers"]: import("../../matrix/api")[K]}} api
* @param {{[K in "getStateEvent" | "getStateEventOuter" | "getJoinedMembers"]: import("../../matrix/api")[K]} | {getEffectivePower: (roomID: string, mxids: string[], api: any) => Promise<{powers: Record<string, number>, allCreators: string[], tombstone: number, roomCreate: Ty.Event.StateOuter<Ty.Event.M_Room_Create>, powerLevels: Ty.Event.M_Power_Levels}>, getJoinedMembers: import("../../matrix/api")["getJoinedMembers"]}} api
*/
async function getViaServers(roomID, api) {
const candidates = []
@@ -138,10 +138,10 @@ async function getViaServers(roomID, api) {
candidates.push(reg.ooye.server_name)
// Candidate 1: Highest joined non-sim non-bot power level user in the room
// https://github.com/matrix-org/matrix-react-sdk/blob/552c65db98b59406fb49562e537a2721c8505517/src/utils/permalinks/Permalinks.ts#L172
const {allCreators, powerLevels} = await getEffectivePower(roomID, [bot], api)
const call = "getEffectivePower" in api ? api.getEffectivePower(roomID, [bot], api) : getEffectivePower(roomID, [bot], api)
const {allCreators, powerLevels} = await call
const sorted = allCreators.concat(Object.entries(powerLevels.users ?? {}).sort((a, b) => b[1] - a[1]).map(([mxid]) => mxid)) // Highest...
for (const power of sorted) {
const mxid = power[0]
for (const mxid of sorted) {
if (!(mxid in joined)) continue // joined...
if (userRegex.some(r => mxid.match(r))) continue // non-sim non-bot...
const match = mxid.match(/:(.*)/)

View File

@@ -3,7 +3,7 @@
const e = new Error("Custom error")
const {test} = require("supertape")
const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers} = require("./utils")
const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion} = require("./utils")
const util = require("util")
/** @param {string[]} mxids */
@@ -88,9 +88,42 @@ test("MatrixStringBuilder: complete code coverage", t => {
})
})
/**
* @param {string[]} [creators]
* @param {{[x: string]: number}} [users]
* @param {string} [roomVersion]
*/
function mockGetEffectivePower(creators = ["@_ooye_bot:cadence.moe"], users = {}, roomVersion = "12") {
return async function getEffectivePower(roomID, mxids) {
return {
allCreators: creators,
powerLevels: {users},
powers: mxids.reduce((a, mxid) => {
if (creators.includes(mxid) && roomHasAtLeastVersion(roomVersion, 12)) a[mxid] = Infinity
else if (mxid in users) a[mxid] = users[mxid]
else a[mxid] = 0
return a
}, {}),
roomCreate: {
type: "m.room.create",
state_key: "",
sender: creators[0],
content: {
additional_creators: creators.slice(1),
room_version: roomVersion
},
room_id: roomID,
origin_server_ts: 0,
event_id: "$create"
},
tombstone: roomVersion === "12" ? 150 : 100,
}
}
}
test("getViaServers: returns the server name if the room only has sim users", async t => {
const result = await getViaServers("!baby", {
getStateEvent: async () => ({}),
getEffectivePower: mockGetEffectivePower(),
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe"])
})
t.deepEqual(result, ["cadence.moe"])
@@ -98,7 +131,7 @@ test("getViaServers: returns the server name if the room only has sim users", as
test("getViaServers: also returns the most popular servers in order", async t => {
const result = await getViaServers("!baby", {
getStateEvent: async () => ({}),
getEffectivePower: mockGetEffectivePower(),
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid"])
})
t.deepEqual(result, ["cadence.moe", "thecollective.invalid", "selfhosted.invalid"])
@@ -106,20 +139,27 @@ test("getViaServers: also returns the most popular servers in order", async t =>
test("getViaServers: does not return IP address servers", async t => {
const result = await getViaServers("!baby", {
getStateEvent: async () => ({}),
getEffectivePower: mockGetEffectivePower(),
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:45.77.232.172:8443", "@cadence:[::1]:8443", "@cadence:123example.456example.invalid"])
})
t.deepEqual(result, ["cadence.moe", "123example.456example.invalid"])
})
test("getViaServers: also returns the highest power level user (v12 creator)", async t => {
const result = await getViaServers("!baby", {
getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe", "@singleuser:selfhosted.invalid"], {
"@moderator:tractor.invalid": 50
}),
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@moderator:tractor.invalid"])
})
t.deepEqual(result, ["cadence.moe", "selfhosted.invalid", "thecollective.invalid", "tractor.invalid"])
})
test("getViaServers: also returns the highest power level user (100)", async t => {
const result = await getViaServers("!baby", {
getStateEvent: async () => ({
users: {
"@moderator:tractor.invalid": 50,
"@singleuser:selfhosted.invalid": 100,
"@_ooye_bot:cadence.moe": 100
}
getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe"], {
"@moderator:tractor.invalid": 50,
"@singleuser:selfhosted.invalid": 100
}),
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@moderator:tractor.invalid"])
})
@@ -128,11 +168,8 @@ test("getViaServers: also returns the highest power level user (100)", async t =
test("getViaServers: also returns the highest power level user (50)", async t => {
const result = await getViaServers("!baby", {
getStateEvent: async () => ({
users: {
"@moderator:tractor.invalid": 50,
"@_ooye_bot:cadence.moe": 100
}
getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe"], {
"@moderator:tractor.invalid": 50
}),
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"])
})
@@ -141,38 +178,23 @@ test("getViaServers: also returns the highest power level user (50)", async t =>
test("getViaServers: returns at most 4 results", async t => {
const result = await getViaServers("!baby", {
getStateEvent: async () => ({
users: {
"@moderator:tractor.invalid": 50,
"@singleuser:selfhosted.invalid": 100,
"@_ooye_bot:cadence.moe": 100
}
getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe"], {
"@moderator:tractor.invalid": 50,
"@singleuser:selfhosted.invalid": 100
}),
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@cadence:123example.456example.invalid"])
})
t.deepEqual(result.length, 4)
})
test("getViaServers: returns results even when power levels can't be fetched", async t => {
const result = await getViaServers("!baby", {
getStateEvent: async () => {
throw new Error("event not found or something")
},
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@singleuser:selfhosted.invalid", "@hazel:thecollective.invalid", "@cadence:123example.456example.invalid"])
})
t.deepEqual(result.length, 4)
})
test("getViaServers: only considers power levels of currently joined members", async t => {
const result = await getViaServers("!baby", {
getStateEvent: async () => ({
users: {
"@moderator:tractor.invalid": 50,
"@former_moderator:missing.invalid": 100,
"@_ooye_bot:cadence.moe": 100
}
getEffectivePower: mockGetEffectivePower(["@_ooye_bot:cadence.moe", "@former_moderator:missing.invalid"], {
"@moderator:tractor.invalid": 50
}),
getJoinedMembers: async () => joinedList(["@_ooye_bot:cadence.moe", "@_ooye_hazel:cadence.moe", "@cadence:cadence.moe", "@moderator:tractor.invalid", "@hazel:thecollective.invalid", "@june:thecollective.invalid", "@singleuser:selfhosted.invalid"])
})
t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"])
})
module.exports.mockGetEffectivePower = mockGetEffectivePower

View File

@@ -26,6 +26,8 @@ const utils = sync.require("./converters/utils")
const api = sync.require("../matrix/api")
/** @type {import("../d2m/actions/create-room")} */
const createRoom = sync.require("../d2m/actions/create-room")
/** @type {import("../matrix/room-upgrade")} */
const roomUpgrade = require("../matrix/room-upgrade")
const {reg} = require("../matrix/read-registration")
let lastReportedEvent = 0
@@ -171,9 +173,8 @@ async function onRetryReactionAdd(reactionEvent) {
// To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator
if (reactionEvent.sender !== event.sender) {
// Check if it's a room moderator
const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
const powerLevel = powerLevelsStateContent.users?.[reactionEvent.sender] || 0
if (powerLevel < 50) return
const {powers: {[reactionEvent.sender]: senderPower}, powerLevels} = await utils.getEffectivePower(roomID, [reactionEvent.sender], api)
if (senderPower < (powerLevels.state_default ?? 50)) return
}
// Retry
@@ -330,6 +331,11 @@ async event => {
if (event.state_key[0] !== "@") return
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
if (event.state_key === bot) {
const upgraded = await roomUpgrade.onBotMembership(event)
if (upgraded) return
}
if (event.content.membership === "invite" && event.state_key === bot) {
// We were invited to a room. We should join, and register the invite details for future reference in web.
let attemptedApiMessage = "According to unsigned invite data."
@@ -342,10 +348,10 @@ async event => {
attemptedApiMessage = "According to unsigned invite data. SSS API unavailable: " + e.toString()
}
}
const name = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.name", "name")
const topic = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.topic", "topic")
const avatar = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.avatar", "url")
const creationType = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.create", "type")
const name = getFromInviteRoomState(inviteRoomState, "m.room.name", "name")
const topic = getFromInviteRoomState(inviteRoomState, "m.room.topic", "topic")
const avatar = getFromInviteRoomState(inviteRoomState, "m.room.avatar", "url")
const creationType = getFromInviteRoomState(inviteRoomState, "m.room.create", "type")
if (!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! (${attemptedApiMessage})`)
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, creationType, name, topic, avatar)
@@ -368,18 +374,14 @@ async event => {
if (!exists) return // don't cache members in unbridged rooms
// Member is here
let powerLevel = 0
try {
/** @type {Ty.Event.M_Power_Levels} */
const powerLevelsEvent = await api.getStateEvent(event.room_id, "m.room.power_levels", "")
powerLevel = powerLevelsEvent.users?.[event.state_key] ?? powerLevelsEvent.users_default ?? 0
} catch (e) {}
let {powers: {[event.state_key]: memberPower}, tombstone} = await utils.getEffectivePower(event.room_id, [event.state_key], api)
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(
event.room_id, event.state_key,
displayname, avatar_url, powerLevel,
displayname, avatar_url, powerLevel
displayname, avatar_url, memberPower,
displayname, avatar_url, memberPower
)
}))
@@ -390,11 +392,22 @@ 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) {
db.prepare("UPDATE member_cache SET power_level = ? WHERE room_id = ? AND mxid = ?").run(newPower[mxid] || 0, event.room_id, mxid)
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)
}
}
}))
sync.addTemporaryListener(as, "type:m.room.tombstone", guard("m.room.tombstone",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Tombstone>} event
*/
async event => {
await roomUpgrade.onTombstone(event)
}))
module.exports.stringifyErrorStack = stringifyErrorStack
module.exports.sendError = sendError