From 9e1fda74903412bf69c2254e0eb1a7e9e6e13dfb Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 28 Aug 2025 15:21:22 -0600 Subject: [PATCH 1/3] First draft of an MSC4333 (and MSC4332) structure --- src/client.ts | 3 + src/models/msc4332-commands.ts | 185 +++++++++++++++++++++++++++++++++ src/models/msc4333-room-map.ts | 135 ++++++++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 src/models/msc4332-commands.ts create mode 100644 src/models/msc4333-room-map.ts diff --git a/src/client.ts b/src/client.ts index 07cf8547c3..a537d2fea6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -247,6 +247,7 @@ import { } from "./oidc/index.ts"; import { type EmptyObject } from "./@types/common.ts"; import { UnsupportedDelayedEventsEndpointError } from "./errors.ts"; +import {MSC4333RoomMap} from "./models/msc4333-room-map.ts"; export type Store = IStore; @@ -1279,6 +1280,8 @@ export class MatrixClient extends TypedEventEmitter; + + /** + * The description of the command. + */ + description: MTextContentBlock; +} + +/** + * A rendered MSC4332 command, expected to be sent as an `m.room.message` event to the room. + */ +export interface MSC4332MRoomMessageContent { + body: string; + msgtype: MsgType.Text; + "m.mentions": { + user_ids: string[]; + }; + "org.matrix.msc4332.command": { + syntax: string; + variables: Record; + }; +} + +/** + * An MSC4332 command. + */ +export class MSC4332BotCommand { + /** + * Creates a new MSC4332BotCommand object from the given parent bot commands definition and the specific command definition. + * @param botCommands The parent bot commands object. + * @param definition The command definition. + * @throws Error If the command definition is invalid. + */ + public constructor(public readonly botCommands: MSC4332BotCommands, public readonly definition: MSC4332BotCommandDefinition) { + if (!this.definition.syntax) { + throw new Error("No syntax"); + } + if (!this.definition.variables) { + this.definition.variables = {}; + } + for (const [name, variable] of Object.entries(this.definition.variables)) { + // XXX: This is not how you search for a plaintext representation. + if (!variable["m.text"]?.[0]?.body) { + throw new Error(`Variable ${name} has no body`); + } + } + // XXX: This is not how you search for a plaintext representation. + if (!this.definition.description || !this.definition.description["m.text"]?.[0]?.body) { + throw new Error("No description"); + } + } + + /** + * Renders the command to a room message event content object using the supplied variables as replacements. + * @param variables The variables to populate. + * @returns The rendered command. + * @throws Error If any of the variables supplied are not defined, or if any variables are not supplied. + */ + public render(variables: Record): MSC4332MRoomMessageContent { + let rendered = this.definition.syntax; + for (const [name, val] of Object.entries(variables)) { + const variable = this.definition.variables[name]; + if (!variable) { + throw new Error(`Variable ${name} not defined`); + } + rendered = rendered.replace(`{${name}}`, val); + } + for (const name of Object.keys(this.definition.variables)) { + if (!variables[name]) { + throw new Error(`Variable ${name} not supplied`); + } + } + + return { + body: `${this.botCommands.sigil}${rendered}`, + msgtype: MsgType.Text, + "m.mentions": { + user_ids: [this.botCommands.userId], + }, + "org.matrix.msc4332.command": { + syntax: this.definition.syntax, + variables, + }, + }; + } +} diff --git a/src/models/msc4333-room-map.ts b/src/models/msc4333-room-map.ts new file mode 100644 index 0000000000..096fc44486 --- /dev/null +++ b/src/models/msc4333-room-map.ts @@ -0,0 +1,135 @@ +/* +Copyright 2025 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type {MatrixClient} from "../client.ts"; +import {Direction} from "./event-timeline.ts"; +import { + MSC4332BotCommand, + type MSC4332BotCommandDefinition, + MSC4332BotCommands, + type MSC4332MRoomMessageContent +} from "./msc4332-commands.ts"; +import {KnownMembership} from "../@types/membership.ts"; + +export interface MSC4333RoomModerationConfig { + managementRoomId: string; + botUserId: string; + banCommand: MSC4332BotCommand; + kickCommand: MSC4332BotCommand; + redactEventCommand: MSC4332BotCommand; + redactUserCommand: MSC4332BotCommand; +} + +interface MSC4333ModerationCommand { + use: string; + prefill_variables?: Record; +} + +export class MSC4333RoomMap { + public constructor(private client: MatrixClient) { + } + + public getModerationConfigFor(roomId: string): MSC4333RoomModerationConfig | null { + for (const room of this.client.getRooms()) { + const roomState = room.getLiveTimeline().getState(Direction.Forward)!; + + const moderationConfigs = roomState.getStateEvents("org.matrix.msc4333.moderation_config"); + for (const moderationConfig of moderationConfigs) { + if (moderationConfig.getStateKey() !== moderationConfig.getSender()) { + continue; // not a config for a bot + } + if (room.getMember(moderationConfig.getSender()!)?.membership !== KnownMembership.Join) { + continue; // bot isn't in the room + } + const protectedRooms = moderationConfig.getContent()["protected_rooms"] as string[]; + if (!protectedRooms?.includes(roomId)) { + continue; // not a protected room for this management room + } + + const botCommands = roomState.getStateEvents("org.matrix.msc4332.commands", moderationConfig.getSender()!); + if (!botCommands) { + continue; // not a bot with commands + } + const parsedCommands = new MSC4332BotCommands(botCommands); + + const parse = (action: string): MSC4333ActionCommand | null => { + const actionConfig = moderationConfig.getContent()[action] as MSC4333ModerationCommand; + const command = parsedCommands.getCommand(actionConfig.use); + if (!command) { + return null; + } + return new MSC4333ActionCommand(parsedCommands, { + prefillVariables: actionConfig.prefill_variables, + ...command.definition, + }); + }; + + const banCommand = parse("ban"); + const kickCommand = parse("kick"); + const redactEventCommand = parse("redact_event"); + const redactUserCommand = parse("redact_user"); + if (banCommand && kickCommand && redactEventCommand && redactUserCommand) { + return { + managementRoomId: moderationConfig.getRoomId()!, + botUserId: moderationConfig.getSender()!, + banCommand, + kickCommand, + redactEventCommand, + redactUserCommand, + }; + } + } + } + + return null; // no config found + } +} + +export interface MSC4333ModerationCommandDefinition extends MSC4332BotCommandDefinition { + prefillVariables?: Record; +} + +export class MSC4333ActionCommand extends MSC4332BotCommand { + public constructor(botCommands: MSC4332BotCommands, definition: MSC4333ModerationCommandDefinition) { + super(botCommands, definition); + } + + public renderAsUserAction(againstUserId: string, inRoomId: string, forReason: string): MSC4332MRoomMessageContent { + return super.render({ + // Apply prefill first so we can override it + ...(this.definition as MSC4333ModerationCommandDefinition).prefillVariables, + + // Supply everything we can + userId: againstUserId, + roomId: inRoomId, + reason: forReason, + permalink: `https://matrix.to/#/${encodeURIComponent(againstUserId)}`, + }); + } + + public renderAsEventAction(againstEventId: string, inRoomId: string, forReason: string): MSC4332MRoomMessageContent { + return super.render({ + // Apply prefill first so we can override it + ...(this.definition as MSC4333ModerationCommandDefinition).prefillVariables, + + // Supply everything we can + eventId: againstEventId, + roomId: inRoomId, + reason: forReason, + permalink: `https://matrix.to/#/${encodeURIComponent(inRoomId)}/${encodeURIComponent(againstEventId)}`, + }); + } +} From 1de38700900fe5447d7852268e2d9fff97962ea7 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 28 Aug 2025 15:41:31 -0600 Subject: [PATCH 2/3] Actually match the MSC --- src/models/msc4333-room-map.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/models/msc4333-room-map.ts b/src/models/msc4333-room-map.ts index 096fc44486..cadba6febe 100644 --- a/src/models/msc4333-room-map.ts +++ b/src/models/msc4333-room-map.ts @@ -54,7 +54,7 @@ export class MSC4333RoomMap { if (room.getMember(moderationConfig.getSender()!)?.membership !== KnownMembership.Join) { continue; // bot isn't in the room } - const protectedRooms = moderationConfig.getContent()["protected_rooms"] as string[]; + const protectedRooms = moderationConfig.getContent()["protected_room_ids"] as string[]; if (!protectedRooms?.includes(roomId)) { continue; // not a protected room for this management room } @@ -66,8 +66,8 @@ export class MSC4333RoomMap { const parsedCommands = new MSC4332BotCommands(botCommands); const parse = (action: string): MSC4333ActionCommand | null => { - const actionConfig = moderationConfig.getContent()[action] as MSC4333ModerationCommand; - const command = parsedCommands.getCommand(actionConfig.use); + const actionConfig = moderationConfig.getContent()["commands"]?.[action] as MSC4333ModerationCommand; + const command = parsedCommands.getCommand(actionConfig?.use); if (!command) { return null; } From b0cfd4885760f12a0cf3d0462197398463e9fdae Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 28 Aug 2025 18:36:27 -0600 Subject: [PATCH 3/3] Make it easier to use --- src/models/msc4332-commands.ts | 4 +-- src/models/msc4333-room-map.ts | 52 ++++++++++++++++++++++------------ 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/models/msc4332-commands.ts b/src/models/msc4332-commands.ts index 875518956c..42c916b368 100644 --- a/src/models/msc4332-commands.ts +++ b/src/models/msc4332-commands.ts @@ -160,12 +160,12 @@ export class MSC4332BotCommand { for (const [name, val] of Object.entries(variables)) { const variable = this.definition.variables[name]; if (!variable) { - throw new Error(`Variable ${name} not defined`); + continue; // don't error - it's possible the caller is giving more context for compatibility reasons } rendered = rendered.replace(`{${name}}`, val); } for (const name of Object.keys(this.definition.variables)) { - if (!variables[name]) { + if (variables[name] === undefined || variables[name] === null) { throw new Error(`Variable ${name} not supplied`); } } diff --git a/src/models/msc4333-room-map.ts b/src/models/msc4333-room-map.ts index cadba6febe..1c17bbb154 100644 --- a/src/models/msc4333-room-map.ts +++ b/src/models/msc4333-room-map.ts @@ -27,10 +27,10 @@ import {KnownMembership} from "../@types/membership.ts"; export interface MSC4333RoomModerationConfig { managementRoomId: string; botUserId: string; - banCommand: MSC4332BotCommand; - kickCommand: MSC4332BotCommand; - redactEventCommand: MSC4332BotCommand; - redactUserCommand: MSC4332BotCommand; + banCommand: MSC4333UserActionCommand; + kickCommand: MSC4333UserActionCommand; + redactEventCommand: MSC4333EventActionCommand; + redactUserCommand: MSC4333UserActionCommand; } interface MSC4333ModerationCommand { @@ -65,22 +65,26 @@ export class MSC4333RoomMap { } const parsedCommands = new MSC4332BotCommands(botCommands); - const parse = (action: string): MSC4333ActionCommand | null => { + const parse = (action: string): E | null => { const actionConfig = moderationConfig.getContent()["commands"]?.[action] as MSC4333ModerationCommand; const command = parsedCommands.getCommand(actionConfig?.use); if (!command) { return null; } - return new MSC4333ActionCommand(parsedCommands, { + const definition: MSC4333ModerationCommandDefinition = { prefillVariables: actionConfig.prefill_variables, ...command.definition, - }); + }; + if (action === "redact_event") { + return new MSC4333EventActionCommand(parsedCommands, definition) as E; + } + return new MSC4333UserActionCommand(parsedCommands, definition) as E; }; - const banCommand = parse("ban"); - const kickCommand = parse("kick"); - const redactEventCommand = parse("redact_event"); - const redactUserCommand = parse("redact_user"); + const banCommand = parse("ban"); + const kickCommand = parse("kick"); + const redactEventCommand = parse("redact_event"); + const redactUserCommand = parse("redact_user"); if (banCommand && kickCommand && redactEventCommand && redactUserCommand) { return { managementRoomId: moderationConfig.getRoomId()!, @@ -102,13 +106,17 @@ export interface MSC4333ModerationCommandDefinition extends MSC4332BotCommandDef prefillVariables?: Record; } -export class MSC4333ActionCommand extends MSC4332BotCommand { - public constructor(botCommands: MSC4332BotCommands, definition: MSC4333ModerationCommandDefinition) { - super(botCommands, definition); +export type MSC4333ActionCommand = MSC4333UserActionCommand | MSC4333EventActionCommand; + +export class MSC4333UserActionCommand { + private command: MSC4332BotCommand; + + public constructor(botCommands: MSC4332BotCommands, private definition: MSC4333ModerationCommandDefinition) { + this.command = new MSC4332BotCommand(botCommands, definition); } - public renderAsUserAction(againstUserId: string, inRoomId: string, forReason: string): MSC4332MRoomMessageContent { - return super.render({ + public render(againstUserId: string, inRoomId: string, forReason: string): MSC4332MRoomMessageContent { + return this.command.render({ // Apply prefill first so we can override it ...(this.definition as MSC4333ModerationCommandDefinition).prefillVariables, @@ -119,9 +127,17 @@ export class MSC4333ActionCommand extends MSC4332BotCommand { permalink: `https://matrix.to/#/${encodeURIComponent(againstUserId)}`, }); } +} + +export class MSC4333EventActionCommand { + private command: MSC4332BotCommand; + + public constructor(botCommands: MSC4332BotCommands, private definition: MSC4333ModerationCommandDefinition) { + this.command = new MSC4332BotCommand(botCommands, definition); + } - public renderAsEventAction(againstEventId: string, inRoomId: string, forReason: string): MSC4332MRoomMessageContent { - return super.render({ + public render(againstEventId: string, inRoomId: string, forReason: string): MSC4332MRoomMessageContent { + return this.command.render({ // Apply prefill first so we can override it ...(this.definition as MSC4333ModerationCommandDefinition).prefillVariables,