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) { + 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] === undefined || variables[name] === null) { + 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..1c17bbb154 --- /dev/null +++ b/src/models/msc4333-room-map.ts @@ -0,0 +1,151 @@ +/* +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: MSC4333UserActionCommand; + kickCommand: MSC4333UserActionCommand; + redactEventCommand: MSC4333EventActionCommand; + redactUserCommand: MSC4333UserActionCommand; +} + +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_room_ids"] 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): E | null => { + const actionConfig = moderationConfig.getContent()["commands"]?.[action] as MSC4333ModerationCommand; + const command = parsedCommands.getCommand(actionConfig?.use); + if (!command) { + return null; + } + 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"); + 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 type MSC4333ActionCommand = MSC4333UserActionCommand | MSC4333EventActionCommand; + +export class MSC4333UserActionCommand { + private command: MSC4332BotCommand; + + public constructor(botCommands: MSC4332BotCommands, private definition: MSC4333ModerationCommandDefinition) { + this.command = new MSC4332BotCommand(botCommands, definition); + } + + 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, + + // Supply everything we can + userId: againstUserId, + roomId: inRoomId, + reason: forReason, + 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 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, + + // Supply everything we can + eventId: againstEventId, + roomId: inRoomId, + reason: forReason, + permalink: `https://matrix.to/#/${encodeURIComponent(inRoomId)}/${encodeURIComponent(againstEventId)}`, + }); + } +}