// Copyright 2022 - 2023 Gnuxie // Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: Apache-2.0 // // SPDX-FileAttributionText: // This modified file incorporates work from mjolnir // https://github.com/matrix-org/mjolnir // import { StandardClientsInRoomMap, DefaultEventDecoder, ClientsInRoomMap, Task, Logger, ActionException, ActionExceptionKind, ConfigRecoverableError, } from "@the-draupnir-project/matrix-protection-suite"; import { BotSDKMatrixAccountData, ClientCapabilityFactory, MatrixSendClient, RoomStateManagerFactory, SafeMatrixEmitter, joinedRoomsSafe, } from "@the-draupnir-project/matrix-protection-suite-for-matrix-bot-sdk"; import { IConfig } from "./config"; import { Draupnir } from "./Draupnir"; import { DraupnirFactory } from "./draupnirfactory/DraupnirFactory"; import { WebAPIs } from "./webapis/WebAPIs"; import { isStringUserID, StringUserID, MatrixRoomID, } from "@the-draupnir-project/matrix-basic-types"; import { Err, Ok, Result, isError } from "@gnuxie/typescript-result"; import { DraupnirRestartError, SafeModeToggle, SafeModeToggleOptions, } from "./safemode/SafeModeToggle"; import { SafeModeDraupnir } from "./safemode/DraupnirSafeMode"; import { ResultError } from "@gnuxie/typescript-result"; import { SafeModeCause, SafeModeReason } from "./safemode/SafeModeCause"; import { SafeModeBootOption } from "./safemode/BootOption"; import { TopLevelStores } from "./backingstore/DraupnirStores"; import { patchMatrixClient } from "./utils"; import { loadZeroTouchDeployRoomFromConfig, ZERO_TOUCH_DEPLOY_ROOM_ACCOUNT_DATA_TYPE, ZeroTouchDeployRoomAccountDataSchema, } from "./managedRoomAccountData"; const log = new Logger("DraupnirBotMode"); export function constructWebAPIs(draupnir: Draupnir): WebAPIs { return new WebAPIs( draupnir.reportManager, draupnir.config, draupnir.synapseHTTPAntispam ); } /** * The bot mode toggle allows the entrypoint, either src/index.ts or * manual test scripts, to setup and control Draupnir. * This includes the webAPIS that accompany Draupnir in bot mode. * * The appservice also implements `SafeModeToggle` but has different requirements. * This interface is exlusively used by the entrypoints to draupnir's bot mode. */ interface BotModeTogle extends SafeModeToggle, AsyncDisposable { encryptionInitialized(): Promise; startFromScratch( options?: SafeModeToggleOptions ): Promise>; maybeRecoverWithSafeMode( error: ResultError, options?: SafeModeToggleOptions ): Promise>; } export class DraupnirBotModeToggle implements BotModeTogle { private draupnir: Draupnir | null = null; private safeModeDraupnir: SafeModeDraupnir | null = null; private webAPIs: WebAPIs | null = null; private constructor( private readonly clientUserID: StringUserID, private readonly managementRoom: MatrixRoomID, private readonly clientsInRoomMap: ClientsInRoomMap, private readonly roomStateManagerFactory: RoomStateManagerFactory, private readonly draupnirFactory: DraupnirFactory, private readonly matrixEmitter: SafeMatrixEmitter, private readonly config: IConfig ) { this.matrixEmitter.on("room.invite", (roomID, event) => { this.clientsInRoomMap.handleTimelineEvent(roomID, event); }); this.matrixEmitter.on("room.event", (roomID, event) => { this.roomStateManagerFactory.handleTimelineEvent(roomID, event); this.clientsInRoomMap.handleTimelineEvent(roomID, event); }); this.matrixEmitter.on("room.leave", (roomID, event) => { this.roomStateManagerFactory.handleTimelineEvent(roomID, event); this.clientsInRoomMap.handleTimelineEvent(roomID, event); }); } public static async create( client: MatrixSendClient, matrixEmitter: SafeMatrixEmitter, config: IConfig, stores: TopLevelStores ): Promise { patchMatrixClient(); const clientUserID = await client.getUserId(); if (!isStringUserID(clientUserID)) { throw new TypeError(`${clientUserID} is not a valid mxid`); } const clientsInRoomMap = new StandardClientsInRoomMap(); // needed to have accurate join infomration. ( await clientsInRoomMap.makeClientRooms(clientUserID, async () => joinedRoomsSafe(client) ) ).expect("Unable to create ClientRooms"); const clientCapabilityFactory = new ClientCapabilityFactory( clientsInRoomMap, DefaultEventDecoder ); const clientPlatform = clientCapabilityFactory.makeClientPlatform( clientUserID, client ); const managementRoom = ( await loadZeroTouchDeployRoomFromConfig( config.managementRoom, config.initialManager, new BotSDKMatrixAccountData( ZERO_TOUCH_DEPLOY_ROOM_ACCOUNT_DATA_TYPE, ZeroTouchDeployRoomAccountDataSchema, client ), clientPlatform, clientUserID, { // Draupnir doesn't allow permalinks for the config.managementRoom, allowPermalinkForRoomConfig: false, configuredRoomPropertyName: "config.managementRoom", configuredInitialManagerPropertyName: "config.initialManager", } ) ).expect("Failed to load or create the Draupnir management room"); const clientProvider = async (userID: StringUserID) => { if (userID !== clientUserID) { throw new TypeError(`Bot mode shouldn't be requesting any other mxids`); } return client; }; const roomStateManagerFactory = new RoomStateManagerFactory( clientsInRoomMap, clientProvider, DefaultEventDecoder, stores.roomStateBackingStore, stores.hashStore ); const draupnirFactory = new DraupnirFactory( clientsInRoomMap, clientCapabilityFactory, clientProvider, roomStateManagerFactory, stores ); return new DraupnirBotModeToggle( clientUserID, managementRoom, clientsInRoomMap, roomStateManagerFactory, draupnirFactory, matrixEmitter, config ); } public async switchToDraupnir( options?: SafeModeToggleOptions ): Promise> { if (this.draupnir !== null) { return ResultError.Result( `There is a draupnir for ${this.clientUserID} already running` ); } this.stopSafeModeDraupnir(); const draupnirResult = await this.draupnirFactory.makeDraupnir( this.clientUserID, this.managementRoom, this.config, this ); if (isError(draupnirResult)) { const safeModeResult = await this.maybeRecoverWithSafeMode( draupnirResult.error, options ); if (isError(safeModeResult)) { return safeModeResult; } else { return DraupnirRestartError.Result( "Draupnir failed to start, switching to safe mode.", { safeModeDraupnir: safeModeResult.ok } ); } } this.draupnir = draupnirResult.ok; this.draupnir.start(); if (options?.sendStatusOnStart) { void Task(this.draupnir.startupComplete()); try { this.webAPIs = constructWebAPIs(this.draupnir); await this.webAPIs.start(); } catch (e) { if (e instanceof Error) { await this.stopDraupnir(); log.error("Failed to start webAPIs", e); return ActionException.Result("Failed to start webAPIs", { exceptionKind: ActionExceptionKind.Unknown, exception: e, }); } else { throw new TypeError("Someone is throwing garbage."); } } } return draupnirResult; } public async switchToSafeMode( cause: SafeModeCause, options?: SafeModeToggleOptions ): Promise> { if (this.safeModeDraupnir !== null) { return ResultError.Result( `There is a safe mode draupnir for ${this.clientUserID} already running` ); } const safeModeResult = await this.draupnirFactory.makeSafeModeDraupnir( this.clientUserID, this.managementRoom, this.config, cause, this ); if (isError(safeModeResult)) { return safeModeResult; } await this.stopDraupnir(); this.safeModeDraupnir = safeModeResult.ok; this.safeModeDraupnir.start(); if (options?.sendStatusOnStart) { this.safeModeDraupnir.startupComplete(); } return safeModeResult; } public async startFromScratch( options?: SafeModeToggleOptions ): Promise> { const draupnirResult = await this.switchToDraupnir(options ?? {}); if (isError(draupnirResult)) { if (draupnirResult.error instanceof DraupnirRestartError) { return Ok(draupnirResult.error.safeModeDraupnir); } else { return draupnirResult; } } return draupnirResult; } public async maybeRecoverWithSafeMode( error: ResultError, options?: SafeModeToggleOptions ): Promise> { switch (this.config.safeMode?.bootOption) { case SafeModeBootOption.Never: return Err(error); case SafeModeBootOption.RecoveryOnly: if (!(error instanceof ConfigRecoverableError)) { return Err(error); } // fallthrough default: log.error( "Failed to start draupnir, switching to safe mode as configured", error ); return await this.switchToSafeMode( { reason: SafeModeReason.InitializationError, error: error, }, options ?? {} ); } } public async encryptionInitialized(): Promise { if (this.draupnir !== null) { this.webAPIs = constructWebAPIs(this.draupnir); await this.webAPIs.start(); await this.draupnir.startupComplete(); } else if (this.safeModeDraupnir !== null) { this.safeModeDraupnir.startupComplete(); } } private async stopDraupnir(): Promise { this.draupnir?.stop(); this.draupnir = null; await this.webAPIs?.stop(); this.webAPIs = null; } private stopSafeModeDraupnir(): void { this.safeModeDraupnir?.stop(); this.safeModeDraupnir = null; } public async [Symbol.asyncDispose](): Promise { await this.stopDraupnir(); this.stopSafeModeDraupnir(); } }