From c556b21ac581d852033c3ba7f591cc7f08473d02 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 27 Sep 2024 12:25:33 +0100 Subject: [PATCH 01/17] Display recovery options in safe mode status information. --- .../DraupnirProtectedRoomsSet.ts | 7 ++-- src/safemode/RecoveryOptions.tsx | 36 +++++++++++++++++++ src/safemode/commands/StatusCommand.tsx | 3 ++ 3 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 src/safemode/RecoveryOptions.tsx diff --git a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts index ce0c99d4..584a568e 100644 --- a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts +++ b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts @@ -42,6 +42,7 @@ import { isError, } from "matrix-protection-suite"; import { + BotSDKAccountDataConfigBackend, BotSDKMatrixAccountData, BotSDKMatrixStateData, MatrixSendClient, @@ -81,10 +82,10 @@ async function makeProtectedRoomsConfig( loggableConfigTracker: LoggableConfigTracker ): Promise> { return await MjolnirProtectedRoomsConfig.createFromStore( - new BotSDKMatrixAccountData( + new BotSDKAccountDataConfigBackend( + client, MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, - MjolnirProtectedRoomsEvent, - client + MjolnirProtectedRoomsEvent ), roomResolver, loggableConfigTracker diff --git a/src/safemode/RecoveryOptions.tsx b/src/safemode/RecoveryOptions.tsx new file mode 100644 index 00000000..6e92fab0 --- /dev/null +++ b/src/safemode/RecoveryOptions.tsx @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { + DeadDocumentJSX, + DocumentNode, +} from "@the-draupnir-project/interface-manager"; +import { ConfigRecoverableError } from "matrix-protection-suite"; +import { SafeModeCause, SafeModeReason } from "./SafeModeCause"; + +export function renderRecoveryOptions(cause: SafeModeCause): DocumentNode { + const recoveryOptions = + cause.reason === SafeModeReason.ByRequest + ? [] + : cause.error instanceof ConfigRecoverableError + ? cause.error.recoveryOptions + : []; + if (recoveryOptions.length === 0) { + return ( + + No recovery options are available for this failure mode. + + ); + } + return ( + + Recovery options are available for this failure mode: +
    + {recoveryOptions.map((option) => ( +
  1. {option.description}
  2. + ))} +
+
+ ); +} diff --git a/src/safemode/commands/StatusCommand.tsx b/src/safemode/commands/StatusCommand.tsx index 2f0d0458..27f06930 100644 --- a/src/safemode/commands/StatusCommand.tsx +++ b/src/safemode/commands/StatusCommand.tsx @@ -17,6 +17,7 @@ import { SOFTWARE_VERSION, } from "../../config"; import { SafeModeInterfaceAdaptor } from "./SafeModeAdaptor"; +import { renderRecoveryOptions } from "../RecoveryOptions"; export function safeModeHeader(): DocumentNode { return ( @@ -84,6 +85,8 @@ export function renderSafeModeStatusInfo(

+ {renderRecoveryOptions(info.safeModeCause)} +
Version: {info.version}
From f9ff3ffdb32c654310b0cb795ea4aff712ce0d8b Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 27 Sep 2024 14:51:49 +0100 Subject: [PATCH 02/17] Allow recover options to be used. Now we just need to anotate the status information/command with a partial command that calls the recover command. --- src/safemode/commands/RecoverCommand.tsx | 83 ++++++++++++++++++++++ src/safemode/commands/SafeModeCommands.tsx | 2 + 2 files changed, 85 insertions(+) create mode 100644 src/safemode/commands/RecoverCommand.tsx diff --git a/src/safemode/commands/RecoverCommand.tsx b/src/safemode/commands/RecoverCommand.tsx new file mode 100644 index 00000000..a0184485 --- /dev/null +++ b/src/safemode/commands/RecoverCommand.tsx @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { Ok, Result, ResultError, isError } from "@gnuxie/typescript-result"; +import { + DeadDocumentJSX, + StringPresentationType, + describeCommand, + tuple, +} from "@the-draupnir-project/interface-manager"; +import { SafeModeDraupnir } from "../DraupnirSafeMode"; +import { SafeModeReason } from "../SafeModeCause"; +import { + ConfigRecoverableError, + ConfigRecoveryOption, +} from "matrix-protection-suite"; +import { SafeModeInterfaceAdaptor } from "./SafeModeAdaptor"; + +export const SafeModeRecoverCommand = describeCommand({ + summary: "Select an availale recovery option to enact.", + parameters: tuple({ + name: "recovery option", + acceptor: StringPresentationType, + description: "The recovery option to enact, e.g. 1.", + }), + async executor( + safeModeDraupnir: SafeModeDraupnir, + _info, + _keywords, + _rest, + optionDesignator + ): Promise> { + const optionNumber = (() => { + try { + return Ok(parseInt(optionDesignator)); + } catch { + return ResultError.Result( + `Unable to parse the recovery option from ${optionDesignator}` + ); + } + })(); + if (isError(optionNumber)) { + return optionNumber; + } + const options = + safeModeDraupnir.cause.reason === SafeModeReason.ByRequest + ? [] + : safeModeDraupnir.cause.error instanceof ConfigRecoverableError + ? safeModeDraupnir.cause.error.recoveryOptions + : []; + const selectedOption = options[optionNumber.ok - 1]; + if (selectedOption === undefined) { + return ResultError.Result( + `No recovery option with the number ${optionNumber.ok} exists.` + ); + } + const recoveryResult = await selectedOption.recover(); + if (isError(recoveryResult)) { + return recoveryResult; + } + return Ok(selectedOption); + }, +}); + +SafeModeInterfaceAdaptor.describeRenderer(SafeModeRecoverCommand, { + JSXRenderer(result) { + if (isError(result)) { + return Ok(undefined); + } + return Ok( + + + The recovery following recovery option has sucessfully been applied: +
+ {result.ok.description} +
+ You may now restart Draupnir with !draupnir restart +
+
+ ); + }, +}); diff --git a/src/safemode/commands/SafeModeCommands.tsx b/src/safemode/commands/SafeModeCommands.tsx index c7ab6426..d5b04cea 100644 --- a/src/safemode/commands/SafeModeCommands.tsx +++ b/src/safemode/commands/SafeModeCommands.tsx @@ -6,8 +6,10 @@ import { StandardCommandTable } from "@the-draupnir-project/interface-manager"; import { SafeModeHelpCommand } from "./HelpCommand"; import { SafeModeStatusCommand } from "./StatusCommand"; import { SafeModeRestartCommand } from "./RestartDraupnirCommand"; +import { SafeModeRecoverCommand } from "./RecoverCommand"; export const SafeModeCommands = new StandardCommandTable("safe mode") .internCommand(SafeModeHelpCommand, ["draupnir", "help"]) .internCommand(SafeModeStatusCommand, ["draupnir", "status"]) + .internCommand(SafeModeRecoverCommand, ["draupnir", "recover"]) .internCommand(SafeModeRestartCommand, ["draupnir", "restart"]); From d4598d9c02d196b3a10e664b8e8dc5f417bc41b8 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 27 Sep 2024 16:31:49 +0100 Subject: [PATCH 03/17] Add recover prompts to the safe mode status info. --- src/safemode/DraupnirSafeMode.ts | 13 ++--- src/safemode/commands/RecoverCommand.tsx | 63 +++++++++++++++++++++--- src/safemode/commands/StatusCommand.tsx | 11 +++-- 3 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/safemode/DraupnirSafeMode.ts b/src/safemode/DraupnirSafeMode.ts index ad297a58..9b3ac402 100644 --- a/src/safemode/DraupnirSafeMode.ts +++ b/src/safemode/DraupnirSafeMode.ts @@ -9,10 +9,7 @@ import { RoomEvent, Task, } from "matrix-protection-suite"; -import { - MatrixAdaptorContext, - sendMatrixEventsFromDeadDocument, -} from "../commands/interface-manager/MPSMatrixInterfaceAdaptor"; +import { MatrixAdaptorContext } from "../commands/interface-manager/MPSMatrixInterfaceAdaptor"; import { StringUserID, StringRoomID, @@ -40,6 +37,7 @@ import { safeModeStatusInfo, } from "./commands/StatusCommand"; import { wrapInRoot } from "../commands/interface-manager/MatrixHelpRenderer"; +import { sendAndAnnotateWithRecoveryOptions } from "./commands/RecoverCommand"; export class SafeModeDraupnir implements MatrixAdaptorContext { public reactionHandler: MatrixReactionHandler; @@ -107,12 +105,11 @@ export class SafeModeDraupnir implements MatrixAdaptorContext { public startupComplete(): void { void Task( - sendMatrixEventsFromDeadDocument( - this.clientPlatform.toRoomMessageSender(), - this.commandRoomID, + sendAndAnnotateWithRecoveryOptions( + this, wrapInRoot(renderSafeModeStatusInfo(safeModeStatusInfo(this))), {} - ) as Promise> + ) ); } diff --git a/src/safemode/commands/RecoverCommand.tsx b/src/safemode/commands/RecoverCommand.tsx index a0184485..a50766c0 100644 --- a/src/safemode/commands/RecoverCommand.tsx +++ b/src/safemode/commands/RecoverCommand.tsx @@ -5,6 +5,7 @@ import { Ok, Result, ResultError, isError } from "@gnuxie/typescript-result"; import { DeadDocumentJSX, + DocumentNode, StringPresentationType, describeCommand, tuple, @@ -14,8 +15,12 @@ import { SafeModeReason } from "../SafeModeCause"; import { ConfigRecoverableError, ConfigRecoveryOption, + RoomEvent, } from "matrix-protection-suite"; import { SafeModeInterfaceAdaptor } from "./SafeModeAdaptor"; +import { sendMatrixEventsFromDeadDocument } from "../../commands/interface-manager/MPSMatrixInterfaceAdaptor"; +import { ARGUMENT_PROMPT_LISTENER } from "../../commands/interface-manager/MatrixPromptForAccept"; +import { MatrixReactionHandler } from "../../commands/interface-manager/MatrixReactionHandler"; export const SafeModeRecoverCommand = describeCommand({ summary: "Select an availale recovery option to enact.", @@ -43,12 +48,7 @@ export const SafeModeRecoverCommand = describeCommand({ if (isError(optionNumber)) { return optionNumber; } - const options = - safeModeDraupnir.cause.reason === SafeModeReason.ByRequest - ? [] - : safeModeDraupnir.cause.error instanceof ConfigRecoverableError - ? safeModeDraupnir.cause.error.recoveryOptions - : []; + const options = getRecoveryOptions(safeModeDraupnir); const selectedOption = options[optionNumber.ok - 1]; if (selectedOption === undefined) { return ResultError.Result( @@ -81,3 +81,54 @@ SafeModeInterfaceAdaptor.describeRenderer(SafeModeRecoverCommand, { ); }, }); + +export function getRecoveryOptions( + safeModeDraupnir: SafeModeDraupnir +): ConfigRecoveryOption[] { + return safeModeDraupnir.cause.reason === SafeModeReason.ByRequest + ? [] + : safeModeDraupnir.cause.error instanceof ConfigRecoverableError + ? safeModeDraupnir.cause.error.recoveryOptions + : []; +} + +export async function sendAndAnnotateWithRecoveryOptions( + safeModeDraupnir: SafeModeDraupnir, + document: DocumentNode, + { replyToEvent }: { replyToEvent?: RoomEvent } +): Promise> { + const reactionMap = MatrixReactionHandler.createItemizedReactionMap( + getRecoveryOptions(safeModeDraupnir).map((_option, index) => + String(index + 1) + ) + ); + const sendResult = await sendMatrixEventsFromDeadDocument( + safeModeDraupnir.clientPlatform.toRoomMessageSender(), + safeModeDraupnir.commandRoomID, + document, + { + replyToEvent, + additionalContent: safeModeDraupnir.reactionHandler.createAnnotation( + ARGUMENT_PROMPT_LISTENER, + reactionMap, + { + command_designator: ["draupnir", "recover"], + read_items: [], + } + ), + } + ); + if (isError(sendResult)) { + return sendResult; + } + if (sendResult.ok[0] === undefined) { + throw new TypeError(`Something is really wrong with the code`); + } + await safeModeDraupnir.reactionHandler.addReactionsToEvent( + safeModeDraupnir.client, + safeModeDraupnir.commandRoomID, + sendResult.ok[0], + reactionMap + ); + return Ok(undefined); +} diff --git a/src/safemode/commands/StatusCommand.tsx b/src/safemode/commands/StatusCommand.tsx index 27f06930..f83e9834 100644 --- a/src/safemode/commands/StatusCommand.tsx +++ b/src/safemode/commands/StatusCommand.tsx @@ -18,6 +18,7 @@ import { } from "../../config"; import { SafeModeInterfaceAdaptor } from "./SafeModeAdaptor"; import { renderRecoveryOptions } from "../RecoveryOptions"; +import { sendAndAnnotateWithRecoveryOptions } from "./RecoverCommand"; export function safeModeHeader(): DocumentNode { return ( @@ -131,10 +132,14 @@ export const SafeModeStatusCommand = describeCommand({ }); SafeModeInterfaceAdaptor.describeRenderer(SafeModeStatusCommand, { - JSXRenderer(result) { - if (isError(result)) { + async arbritraryRenderer(context, eventContext, commandResult) { + if (isError(commandResult)) { return Ok(undefined); } - return Ok({renderSafeModeStatusInfo(result.ok)}); + return await sendAndAnnotateWithRecoveryOptions( + context, + {renderSafeModeStatusInfo(commandResult.ok)}, + { replyToEvent: eventContext.event } + ); }, }); From 8c74023374502ac18df6d21985f6ccc14914a5f7 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 27 Sep 2024 16:32:23 +0100 Subject: [PATCH 04/17] Allow DraupnirSafeMode to respond to prompts. --- src/Draupnir.ts | 4 ++-- src/appservice/bot/AppserviceCommandHandler.ts | 12 ++++++++++-- .../interface-manager/MatrixPromptForAccept.tsx | 9 +++++++++ src/safemode/DraupnirSafeMode.ts | 9 +++++++-- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index ef867b10..f542886a 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -164,11 +164,11 @@ export class Draupnir implements Client, MatrixAdaptorContext { } this.reactionHandler.on( ARGUMENT_PROMPT_LISTENER, - makeListenerForArgumentPrompt(this.commandDispatcher) + makeListenerForArgumentPrompt(this.commandRoomID, this.commandDispatcher) ); this.reactionHandler.on( DEFAUILT_ARGUMENT_PROMPT_LISTENER, - makeListenerForPromptDefault(this.commandDispatcher) + makeListenerForPromptDefault(this.commandRoomID, this.commandDispatcher) ); this.capabilityMessageRenderer = new DraupnirRendererMessageCollector( this.clientPlatform.toRoomMessageSender(), diff --git a/src/appservice/bot/AppserviceCommandHandler.ts b/src/appservice/bot/AppserviceCommandHandler.ts index 2a929099..ab975bd6 100644 --- a/src/appservice/bot/AppserviceCommandHandler.ts +++ b/src/appservice/bot/AppserviceCommandHandler.ts @@ -75,11 +75,19 @@ export class AppserviceCommandHandler { ); this.reactionHandler.on( ARGUMENT_PROMPT_LISTENER, - makeListenerForArgumentPrompt(this.commandDispatcher) + makeListenerForArgumentPrompt( + this.adminRoomID, + this.commandDispatcher, + this.reactionHandler + ) ); this.reactionHandler.on( DEFAUILT_ARGUMENT_PROMPT_LISTENER, - makeListenerForPromptDefault(this.commandDispatcher) + makeListenerForPromptDefault( + this.adminRoomID, + this.commandDispatcher, + this.reactionHandler + ) ); this.JSInterfaceDispatcher = makeAppserviceJSCommandDispatcher( this.appserviceContext diff --git a/src/commands/interface-manager/MatrixPromptForAccept.tsx b/src/commands/interface-manager/MatrixPromptForAccept.tsx index e9e4aebf..1c075330 100644 --- a/src/commands/interface-manager/MatrixPromptForAccept.tsx +++ b/src/commands/interface-manager/MatrixPromptForAccept.tsx @@ -24,6 +24,7 @@ import { readCommand, } from "@the-draupnir-project/interface-manager"; import { Ok, Result } from "@gnuxie/typescript-result"; +import { StringRoomID } from "@the-draupnir-project/matrix-basic-types"; const log = new Logger("MatrixPromptForAccept"); @@ -66,9 +67,13 @@ function continueCommandAcceptingPrompt( export const DEFAUILT_ARGUMENT_PROMPT_LISTENER = "ge.applied-langua.ge.draupnir.default_argument_prompt"; export function makeListenerForPromptDefault( + commandRoomID: StringRoomID, commandDispatcher: MatrixInterfaceCommandDispatcher ): ReactionListener { return function (reactionKey, item, context, reactionMap, annotatedEvent) { + if (annotatedEvent.room_id !== commandRoomID) { + return; + } if (item !== "ok") { return; } @@ -92,9 +97,13 @@ export function makeListenerForPromptDefault( export const ARGUMENT_PROMPT_LISTENER = "ge.applied-langua.ge.draupnir.argument_prompt"; export function makeListenerForArgumentPrompt( + commandRoomID: StringRoomID, commandDispatcher: MatrixInterfaceCommandDispatcher ): ReactionListener { return function (reactionKey, item, context, reactionMap, annotatedEvent) { + if (annotatedEvent.room_id !== commandRoomID) { + return; + } const promptContext = Value.Decode(PromptContext, context); if (isError(promptContext)) { log.error( diff --git a/src/safemode/DraupnirSafeMode.ts b/src/safemode/DraupnirSafeMode.ts index 9b3ac402..1cda0d73 100644 --- a/src/safemode/DraupnirSafeMode.ts +++ b/src/safemode/DraupnirSafeMode.ts @@ -71,16 +71,21 @@ export class SafeModeDraupnir implements MatrixAdaptorContext { ); this.reactionHandler.on( ARGUMENT_PROMPT_LISTENER, - makeListenerForArgumentPrompt(this.commandDispatcher) + makeListenerForArgumentPrompt(this.commandRoomID, this.commandDispatcher) ); this.reactionHandler.on( DEFAUILT_ARGUMENT_PROMPT_LISTENER, - makeListenerForPromptDefault(this.commandDispatcher) + makeListenerForPromptDefault(this.commandRoomID, this.commandDispatcher) ); } handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { this.commandDispatcherTimelineListener(roomID, event); + void Task( + (async () => { + await this.reactionHandler.handleEvent(roomID, event); + })() + ); } handleEventReport(_report: EventReport): void { throw new Error("Method not implemented."); From 9ff0f54ff8979d937f9f44c7c3992e7c4b54be5f Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 27 Sep 2024 17:11:58 +0100 Subject: [PATCH 05/17] Automatically complete prompts from partial commands. Fixes https://github.com/the-draupnir-project/Draupnir/issues/585. --- src/Draupnir.ts | 12 ++++++++-- .../MatrixPromptForAccept.tsx | 23 ++++++++++++++----- src/safemode/DraupnirSafeMode.ts | 12 ++++++++-- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/Draupnir.ts b/src/Draupnir.ts index f542886a..08e0f360 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -164,11 +164,19 @@ export class Draupnir implements Client, MatrixAdaptorContext { } this.reactionHandler.on( ARGUMENT_PROMPT_LISTENER, - makeListenerForArgumentPrompt(this.commandRoomID, this.commandDispatcher) + makeListenerForArgumentPrompt( + this.commandRoomID, + this.commandDispatcher, + this.reactionHandler + ) ); this.reactionHandler.on( DEFAUILT_ARGUMENT_PROMPT_LISTENER, - makeListenerForPromptDefault(this.commandRoomID, this.commandDispatcher) + makeListenerForPromptDefault( + this.commandRoomID, + this.commandDispatcher, + this.reactionHandler + ) ); this.capabilityMessageRenderer = new DraupnirRendererMessageCollector( this.clientPlatform.toRoomMessageSender(), diff --git a/src/commands/interface-manager/MatrixPromptForAccept.tsx b/src/commands/interface-manager/MatrixPromptForAccept.tsx index 1c075330..3fc47008 100644 --- a/src/commands/interface-manager/MatrixPromptForAccept.tsx +++ b/src/commands/interface-manager/MatrixPromptForAccept.tsx @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: Apache-2.0 -import { Logger, Value, isError } from "matrix-protection-suite"; +import { Logger, Task, Value, isError } from "matrix-protection-suite"; import { MatrixReactionHandler, ReactionListener, @@ -50,7 +50,8 @@ function continueCommandAcceptingPrompt( eventContext: MatrixEventContext, promptContext: PromptContext, serializedPrompt: string, - commandDispatcher: MatrixInterfaceCommandDispatcher + commandDispatcher: MatrixInterfaceCommandDispatcher, + reactionHandler: MatrixReactionHandler ): void { const stream = new StandardPresentationArgumentStream( readCommand( @@ -62,13 +63,20 @@ function continueCommandAcceptingPrompt( ) ); commandDispatcher.handleCommandFromPresentationStream(eventContext, stream); + void Task( + reactionHandler.completePrompt( + eventContext.roomID, + eventContext.event.event_id + ) + ); } export const DEFAUILT_ARGUMENT_PROMPT_LISTENER = "ge.applied-langua.ge.draupnir.default_argument_prompt"; export function makeListenerForPromptDefault( commandRoomID: StringRoomID, - commandDispatcher: MatrixInterfaceCommandDispatcher + commandDispatcher: MatrixInterfaceCommandDispatcher, + reactionHandler: MatrixReactionHandler ): ReactionListener { return function (reactionKey, item, context, reactionMap, annotatedEvent) { if (annotatedEvent.room_id !== commandRoomID) { @@ -89,7 +97,8 @@ export function makeListenerForPromptDefault( { event: annotatedEvent, roomID: annotatedEvent.room_id }, promptContext.ok, item, - commandDispatcher + commandDispatcher, + reactionHandler ); }; } @@ -98,7 +107,8 @@ export const ARGUMENT_PROMPT_LISTENER = "ge.applied-langua.ge.draupnir.argument_prompt"; export function makeListenerForArgumentPrompt( commandRoomID: StringRoomID, - commandDispatcher: MatrixInterfaceCommandDispatcher + commandDispatcher: MatrixInterfaceCommandDispatcher, + reactionHandler: MatrixReactionHandler ): ReactionListener { return function (reactionKey, item, context, reactionMap, annotatedEvent) { if (annotatedEvent.room_id !== commandRoomID) { @@ -116,7 +126,8 @@ export function makeListenerForArgumentPrompt( { event: annotatedEvent, roomID: annotatedEvent.room_id }, promptContext.ok, item, - commandDispatcher + commandDispatcher, + reactionHandler ); }; } diff --git a/src/safemode/DraupnirSafeMode.ts b/src/safemode/DraupnirSafeMode.ts index 1cda0d73..17bb4ae5 100644 --- a/src/safemode/DraupnirSafeMode.ts +++ b/src/safemode/DraupnirSafeMode.ts @@ -71,11 +71,19 @@ export class SafeModeDraupnir implements MatrixAdaptorContext { ); this.reactionHandler.on( ARGUMENT_PROMPT_LISTENER, - makeListenerForArgumentPrompt(this.commandRoomID, this.commandDispatcher) + makeListenerForArgumentPrompt( + this.commandRoomID, + this.commandDispatcher, + this.reactionHandler + ) ); this.reactionHandler.on( DEFAUILT_ARGUMENT_PROMPT_LISTENER, - makeListenerForPromptDefault(this.commandRoomID, this.commandDispatcher) + makeListenerForPromptDefault( + this.commandRoomID, + this.commandDispatcher, + this.reactionHandler + ) ); } From b9c05fbb6ba962a108bef4cc181ccfac47ef39ac Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 27 Sep 2024 20:31:26 +0100 Subject: [PATCH 06/17] Update interface-manager for better infrence on arbritrary renderers. --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index d9cb6cf5..2a079caa 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@sentry/node": "^7.17.2", "@sentry/tracing": "^7.17.2", "@sinclair/typebox": "0.32.34", - "@the-draupnir-project/interface-manager": "2.4.0", + "@the-draupnir-project/interface-manager": "2.4.1", "@the-draupnir-project/matrix-basic-types": "^0.2.0", "await-lock": "^2.2.2", "better-sqlite3": "^9.4.3", diff --git a/yarn.lock b/yarn.lock index bb5b30fb..cfa6de47 100644 --- a/yarn.lock +++ b/yarn.lock @@ -256,10 +256,10 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== -"@the-draupnir-project/interface-manager@2.4.0": - version "2.4.0" - resolved "https://registry.yarnpkg.com/@the-draupnir-project/interface-manager/-/interface-manager-2.4.0.tgz#7734309e8cdc709d4f4a63557ac6b192dcb188a0" - integrity sha512-0iKB5nHHUBRMysKOw77/YmPaSrA6d7YtrMZQd7fAE0eqNTZLgHu3vS0fnBQ1AcVM2016Io512up3WYTUVqAqkA== +"@the-draupnir-project/interface-manager@2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@the-draupnir-project/interface-manager/-/interface-manager-2.4.1.tgz#fc93eac495cf89fee0c87786b405a2fbae826b49" + integrity sha512-7L9QzsH/BAgdPSoNaB78ENJKAqbD4sqA0J7LmAUDiM7ygLS1yVrrI2VRN9aCdguVcbuD//fnOCJhSlfL2vr6YQ== dependencies: "@gnuxie/super-cool-stream" "^0.2.1" "@gnuxie/typescript-result" "^1.0.0" From 3c1cb07aa656c705eba194b8db330183548cd5f0 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 30 Sep 2024 11:31:34 +0100 Subject: [PATCH 07/17] Create a fresh safe mode instance when restarting Draupnir. Specifically, when restart Draupnir fails, and safe mode is already running, we will want to create a fresh safe mode instance that contains the relevant recovery options, from the new cause of failure. --- src/DraupnirBotMode.ts | 59 +++++++++++++++++++++++++--------- src/safemode/SafeModeToggle.ts | 22 +++++++++++-- 2 files changed, 63 insertions(+), 18 deletions(-) diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 654c4617..31a9ad50 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -39,8 +39,9 @@ import { StringUserID, MatrixRoomID, } from "@the-draupnir-project/matrix-basic-types"; -import { Result, isError } from "@gnuxie/typescript-result"; +import { Err, Ok, Result, isError } from "@gnuxie/typescript-result"; import { + DraupnirRestartError, SafeModeToggle, SafeModeToggleOptions, } from "./safemode/SafeModeToggle"; @@ -70,6 +71,10 @@ interface BotModeTogle extends SafeModeToggle { startFromScratch( options?: SafeModeToggleOptions ): Promise>; + maybeRecoverWithSafeMode( + error: ResultError, + options?: SafeModeToggleOptions + ): Promise>; } export class DraupnirBotModeToggle implements BotModeTogle { @@ -168,12 +173,13 @@ export class DraupnirBotModeToggle implements BotModeTogle { } public async switchToDraupnir( options?: SafeModeToggleOptions - ): Promise> { + ): 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, @@ -181,7 +187,18 @@ export class DraupnirBotModeToggle implements BotModeTogle { this ); if (isError(draupnirResult)) { - return 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(); @@ -203,7 +220,6 @@ export class DraupnirBotModeToggle implements BotModeTogle { } } } - this.stopSafeModeDraupnir(); return draupnirResult; } public async switchToSafeMode( @@ -239,18 +255,8 @@ export class DraupnirBotModeToggle implements BotModeTogle { ): Promise> { const draupnirResult = await this.switchToDraupnir(options ?? {}); if (isError(draupnirResult)) { - if (this.config.safeMode?.bootIntoOnStartupFailure) { - log.error( - "Failed to start draupnir, switching to safe mode as configured", - draupnirResult.error - ); - return await this.switchToSafeMode( - { - reason: SafeModeReason.InitializationError, - error: draupnirResult.error, - }, - options ?? {} - ); + if (draupnirResult.error instanceof DraupnirRestartError) { + return Ok(draupnirResult.error.safeModeDraupnir); } else { return draupnirResult; } @@ -258,6 +264,27 @@ export class DraupnirBotModeToggle implements BotModeTogle { return draupnirResult; } + public async maybeRecoverWithSafeMode( + error: ResultError, + options?: SafeModeToggleOptions | undefined + ): Promise> { + if (this.config.safeMode?.bootIntoOnStartupFailure) { + log.error( + "Failed to start draupnir, switching to safe mode as configured", + error + ); + return await this.switchToSafeMode( + { + reason: SafeModeReason.InitializationError, + error: error, + }, + options ?? {} + ); + } else { + return Err(error); + } + } + public async encryptionInitialized(): Promise { if (this.draupnir !== null) { try { diff --git a/src/safemode/SafeModeToggle.ts b/src/safemode/SafeModeToggle.ts index ddc831fc..f5913bbd 100644 --- a/src/safemode/SafeModeToggle.ts +++ b/src/safemode/SafeModeToggle.ts @@ -2,13 +2,29 @@ // // SPDX-License-Identifier: AFL-3.0 -import { Result } from "@gnuxie/typescript-result"; +import { Err, Result, ResultError } from "@gnuxie/typescript-result"; import { Draupnir } from "../Draupnir"; import { SafeModeDraupnir } from "./DraupnirSafeMode"; import { SafeModeCause } from "./SafeModeCause"; export type SafeModeToggleOptions = { sendStatusOnStart?: boolean }; +export class DraupnirRestartError extends ResultError { + constructor( + message: string, + public readonly safeModeDraupnir: SafeModeDraupnir + ) { + super(message); + } + + public static Result( + message: string, + options: { safeModeDraupnir: SafeModeDraupnir } + ): Result { + return Err(new DraupnirRestartError(message, options.safeModeDraupnir)); + } +} + export interface SafeModeToggle { /** * Switch the bot to Draupnir mode. @@ -17,7 +33,9 @@ export interface SafeModeToggle { * That means that by the command responds with ticks and crosses, * draupnir will be running or we will still be in safe mode. */ - switchToDraupnir(options?: SafeModeToggleOptions): Promise>; + switchToDraupnir( + options?: SafeModeToggleOptions + ): Promise>; switchToSafeMode( cause: SafeModeCause, options?: SafeModeToggleOptions From 2c8c2892bab1112c708779dc537ec3be11312fa3 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 30 Sep 2024 12:08:52 +0100 Subject: [PATCH 08/17] Allow safe mode to be configured in appservice mode. --- src/config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config.ts b/src/config.ts index 668cc0ce..581d2d61 100644 --- a/src/config.ts +++ b/src/config.ts @@ -287,6 +287,7 @@ export function getProvisionedMjolnirConfig(managementRoomId: string): IConfig { "automaticallyRedactForReasons", "protectAllJoinedRooms", "backgroundDelayMS", + "safeMode", ]; const configTemplate = read(); // we use the standard bot config as a template for every provisioned mjolnir. const unusedKeys = Object.keys(configTemplate).filter( From 6b0e252e2a98a43bcda7a781f0121b019d5c0fe4 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 30 Sep 2024 12:09:33 +0100 Subject: [PATCH 09/17] Make StandardDraupnirManager boot into safe mode too. Appservice bots won't boot into safe mode otherwise. --- .../StandardDraupnirManager.ts | 47 ++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/src/draupnirfactory/StandardDraupnirManager.ts b/src/draupnirfactory/StandardDraupnirManager.ts index dac0b5cb..a18320d8 100644 --- a/src/draupnirfactory/StandardDraupnirManager.ts +++ b/src/draupnirfactory/StandardDraupnirManager.ts @@ -8,7 +8,12 @@ // https://github.com/matrix-org/mjolnir // -import { ActionError, ActionResult, isError } from "matrix-protection-suite"; +import { + ActionError, + ActionResult, + Logger, + isError, +} from "matrix-protection-suite"; import { IConfig } from "../config"; import { DraupnirFactory } from "./DraupnirFactory"; import { Draupnir } from "../Draupnir"; @@ -17,8 +22,14 @@ import { MatrixRoomID, } from "@the-draupnir-project/matrix-basic-types"; import { SafeModeDraupnir } from "../safemode/DraupnirSafeMode"; -import { SafeModeCause } from "../safemode/SafeModeCause"; -import { SafeModeToggle } from "../safemode/SafeModeToggle"; +import { SafeModeCause, SafeModeReason } from "../safemode/SafeModeCause"; +import { + DraupnirRestartError, + SafeModeToggle, +} from "../safemode/SafeModeToggle"; +import { ResultError } from "@gnuxie/typescript-result"; + +const log = new Logger("StandardDraupnirManager"); export class StandardDraupnirManager { private readonly draupnir = new Map(); @@ -61,7 +72,8 @@ export class StandardDraupnirManager { clientUserID: StringUserID, managementRoom: MatrixRoomID, config: IConfig - ): Promise> { + ): Promise> { + this.safeModeDraupnir.delete(clientUserID); const draupnir = await this.draupnirFactory.makeDraupnir( clientUserID, managementRoom, @@ -79,11 +91,34 @@ export class StandardDraupnirManager { draupnir.error, clientUserID ); - return draupnir; + if (config.safeMode?.bootIntoOnStartupFailure) { + log.error( + `Failed to start draupnir ${clientUserID}, switching to safe mode as configured`, + draupnir.error + ); + const safeModeResult = await this.makeSafeModeDraupnir( + clientUserID, + managementRoom, + config, + { + reason: SafeModeReason.InitializationError, + error: draupnir.error, + } + ); + if (isError(safeModeResult)) { + return safeModeResult; + } else { + return DraupnirRestartError.Result( + `Failed to start draupnir ${clientUserID}, switching to safe mode as configured`, + { safeModeDraupnir: safeModeResult.ok } + ); + } + } else { + return draupnir; + } } this.draupnir.set(clientUserID, draupnir.ok); this.failedDraupnir.delete(clientUserID); - this.safeModeDraupnir.delete(clientUserID); draupnir.ok.start(); return draupnir; } From 72020e8ad678d65b91fb1538fff4938f312a04db Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 30 Sep 2024 12:12:42 +0100 Subject: [PATCH 10/17] Integration tests for recoring from protected rooms failure. --- .../integration/safeModeRecoverTest.ts | 43 ++++++++++++ .../commands/recoverCommandDetail.ts | 69 +++++++++++++++++++ .../commands/recoverCommandTest.ts | 25 +++++++ 3 files changed, 137 insertions(+) create mode 100644 test/appservice/integration/safeModeRecoverTest.ts create mode 100644 test/integration/commands/recoverCommandDetail.ts create mode 100644 test/integration/commands/recoverCommandTest.ts diff --git a/test/appservice/integration/safeModeRecoverTest.ts b/test/appservice/integration/safeModeRecoverTest.ts new file mode 100644 index 00000000..06c017ba --- /dev/null +++ b/test/appservice/integration/safeModeRecoverTest.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; +import { MjolnirAppService } from "../../../src/appservice/AppService"; +import { newTestUser } from "../../integration/clientHelper"; +import { StandardProvisionHelper } from "../utils/ProvisionHelper"; +import { setupHarness } from "../utils/harness"; +import { testRecoverAndRestart } from "../../integration/commands/recoverCommandDetail"; + +interface Context extends Mocha.Context { + appservice?: MjolnirAppService; +} + +describe("Test safe mode recovery on a provisioned Draupnir", function () { + beforeEach(async function (this: Context) { + this.appservice = await setupHarness(); + }); + afterEach(function (this: Context) { + if (this.appservice) { + return this.appservice.close(); + } else { + console.warn("Missing Appservice in this context, so cannot stop it."); + return Promise.resolve(); // TS7030: Not all code paths return a value. + } + }); + it("Provisioned draupnir can be recovered from safe mode.", async function (this: Context) { + const appservice = this.appservice; + if (appservice === undefined) { + throw new TypeError(`Test setup failed`); + } + const provisionHelper = new StandardProvisionHelper(appservice); + const moderator = await newTestUser(appservice.config.homeserver.url, { + name: { contains: "moderator" }, + }); + const moderatorUserID = (await moderator.getUserId()) as StringUserID; + const initialDraupnir = ( + await provisionHelper.provisionDraupnir(moderatorUserID) + ).expect("Failed to provision a draupnir for the test"); + await testRecoverAndRestart(moderatorUserID, initialDraupnir); + }); +}); diff --git a/test/integration/commands/recoverCommandDetail.ts b/test/integration/commands/recoverCommandDetail.ts new file mode 100644 index 00000000..e5706184 --- /dev/null +++ b/test/integration/commands/recoverCommandDetail.ts @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, + MjolnirProtectedRoomsEvent, +} from "matrix-protection-suite"; +import { Draupnir } from "../../../src/Draupnir"; +import { + StringRoomID, + StringUserID, +} from "@the-draupnir-project/matrix-basic-types"; +import { isOk } from "@gnuxie/typescript-result"; +import { SafeModeDraupnir } from "../../../src/safemode/DraupnirSafeMode"; +import { DraupnirRestartError } from "../../../src/safemode/SafeModeToggle"; + +async function clobberAccountData(draupnir: Draupnir): Promise { + const existingAccountData = + await draupnir.client.getAccountData( + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE + ); + existingAccountData.rooms.push("clobbered" as StringRoomID); + await draupnir.client.setAccountData( + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, + existingAccountData + ); +} + +export async function testRecoverAndRestart( + sender: StringUserID, + draupnir: Draupnir +): Promise { + const initialSafeModeDraupnir = ( + await draupnir.sendTextCommand( + sender, + "!draupnir safe mode" + ) + ).expect("Failed to switch to safe mode to setup the test"); + await clobberAccountData(draupnir); + const badRestartResult = await initialSafeModeDraupnir.sendTextCommand( + sender, + "!draupnir restart" + ); + if (isOk(badRestartResult)) { + throw new TypeError( + "The restat command should have failed because we clobbered account data." + ); + } + const safeModeDraupnirWithRecoveryOptions = (() => { + if (badRestartResult.error instanceof DraupnirRestartError) { + return badRestartResult.error.safeModeDraupnir; + } else { + throw new TypeError("Expected a DraupnirRestartError"); + } + })(); + ( + await safeModeDraupnirWithRecoveryOptions.sendTextCommand( + sender, + "!draupnir recover 1" + ) + ).expect("Failed to recover the draupnir"); + ( + await safeModeDraupnirWithRecoveryOptions.sendTextCommand( + sender, + "!draupnir restart" + ) + ).expect("Failed to restart the draupnir after recovery was attempted"); +} diff --git a/test/integration/commands/recoverCommandTest.ts b/test/integration/commands/recoverCommandTest.ts new file mode 100644 index 00000000..1dc2bed7 --- /dev/null +++ b/test/integration/commands/recoverCommandTest.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; +import { newTestUser } from "../clientHelper"; +import { DraupnirTestContext, draupnir } from "../mjolnirSetupUtils"; +import { testRecoverAndRestart } from "./recoverCommandDetail"; + +describe("We should be able to restart and recover draupnir when it has bad account data", function () { + it("Recovering protected rooms", async function (this: DraupnirTestContext) { + const moderator = await newTestUser(this.config.homeserverUrl, { + name: { contains: "moderator" }, + }); + await draupnir().client.inviteUser( + await moderator.getUserId(), + draupnir().managementRoomID + ); + await moderator.joinRoom(draupnir().managementRoomID); + await testRecoverAndRestart( + (await moderator.getUserId()) as StringUserID, + draupnir() + ); + } as unknown as Mocha.AsyncFunc); +}); From 756d9c84917beae046ed840edba11a061fc64ff0 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Tue, 1 Oct 2024 11:54:28 +0100 Subject: [PATCH 11/17] Update DraupnirProtectedRoomsSet for more recoverable configs. Added policy list config and protection settings. --- .../DraupnirProtectedRoomsSet.ts | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts index 584a568e..e2bf4e91 100644 --- a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts +++ b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts @@ -16,14 +16,11 @@ import { MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, MissingProtectionCB, - MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, MjolnirPolicyRoomsConfig, MjolnirProtectedRoomsConfig, - MjolnirProtectedRoomsEvent, MjolnirProtectionSettingsEventType, MjolnirProtectionsConfig, - MjolnirWatchedPolicyRoomsEvent, Ok, PolicyListConfig, PolicyRoomManager, @@ -43,7 +40,6 @@ import { } from "matrix-protection-suite"; import { BotSDKAccountDataConfigBackend, - BotSDKMatrixAccountData, BotSDKMatrixStateData, MatrixSendClient, } from "matrix-protection-suite-for-matrix-bot-sdk"; @@ -65,10 +61,9 @@ async function makePolicyListConfig( roomJoiner: RoomJoiner ): Promise> { const result = await MjolnirPolicyRoomsConfig.createFromStore( - new BotSDKMatrixAccountData( - MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, - MjolnirWatchedPolicyRoomsEvent, - client + new BotSDKAccountDataConfigBackend( + client, + MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE ), policyRoomManager, roomJoiner @@ -84,8 +79,7 @@ async function makeProtectedRoomsConfig( return await MjolnirProtectedRoomsConfig.createFromStore( new BotSDKAccountDataConfigBackend( client, - MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, - MjolnirProtectedRoomsEvent + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE ), roomResolver, loggableConfigTracker @@ -117,10 +111,9 @@ async function makeProtectionsManager( return result; } const protectionsConfigResult = await MjolnirProtectionsConfig.create( - new BotSDKMatrixAccountData( - MjolnirEnabledProtectionsEventType, - MjolnirEnabledProtectionsEvent, - client + new BotSDKAccountDataConfigBackend( + client, + MjolnirEnabledProtectionsEventType ), loggableConfigTracker, { From 05f662214cf85dc85569d7d4bb59048f862d24b6 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Tue, 1 Oct 2024 13:12:37 +0100 Subject: [PATCH 12/17] Add WatchedLists and EnabledProtections to config recovery test. --- .../commands/recoverCommandDetail.ts | 90 ++++++++++++++++--- 1 file changed, 80 insertions(+), 10 deletions(-) diff --git a/test/integration/commands/recoverCommandDetail.ts b/test/integration/commands/recoverCommandDetail.ts index e5706184..ba40651e 100644 --- a/test/integration/commands/recoverCommandDetail.ts +++ b/test/integration/commands/recoverCommandDetail.ts @@ -4,6 +4,8 @@ import { MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, + MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, + MjolnirEnabledProtectionsEventType, MjolnirProtectedRoomsEvent, } from "matrix-protection-suite"; import { Draupnir } from "../../../src/Draupnir"; @@ -14,31 +16,51 @@ import { import { isOk } from "@gnuxie/typescript-result"; import { SafeModeDraupnir } from "../../../src/safemode/DraupnirSafeMode"; import { DraupnirRestartError } from "../../../src/safemode/SafeModeToggle"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -async function clobberAccountData(draupnir: Draupnir): Promise { +async function clobberProtectedRooms(client: MatrixSendClient): Promise { const existingAccountData = - await draupnir.client.getAccountData( + await client.getAccountData( MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE ); existingAccountData.rooms.push("clobbered" as StringRoomID); - await draupnir.client.setAccountData( + await client.setAccountData( MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, existingAccountData ); } -export async function testRecoverAndRestart( +async function clobberWatchedLists(client: MatrixSendClient): Promise { + await client.setAccountData(MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, { + references: "cheese", + }); +} + +async function clobberEnabledProtections( + client: MatrixSendClient +): Promise { + await client.setAccountData(MjolnirEnabledProtectionsEventType, { + enabled: 34, + }); +} + +async function goToSafeMode( sender: StringUserID, draupnir: Draupnir -): Promise { - const initialSafeModeDraupnir = ( +): Promise { + return ( await draupnir.sendTextCommand( sender, "!draupnir safe mode" ) ).expect("Failed to switch to safe mode to setup the test"); - await clobberAccountData(draupnir); - const badRestartResult = await initialSafeModeDraupnir.sendTextCommand( +} + +async function recoverAndRestart( + sender: StringUserID, + initialDraupnir: SafeModeDraupnir +): Promise { + const badRestartResult = await initialDraupnir.sendTextCommand( sender, "!draupnir restart" ); @@ -60,10 +82,58 @@ export async function testRecoverAndRestart( "!draupnir recover 1" ) ).expect("Failed to recover the draupnir"); - ( - await safeModeDraupnirWithRecoveryOptions.sendTextCommand( + return ( + await safeModeDraupnirWithRecoveryOptions.sendTextCommand( sender, "!draupnir restart" ) ).expect("Failed to restart the draupnir after recovery was attempted"); } + +type ClobberEffectDescription = { + effect: (client: MatrixSendClient) => Promise; + description: string; +}; + +export async function testClobberEffect( + sender: StringUserID, + initialDraupnir: Draupnir, + effect: ClobberEffectDescription +): Promise { + console.log(`Testing clobber effect: ${effect.description}`); + const initialSafeModeDraupnir = await goToSafeMode(sender, initialDraupnir); + await effect.effect(initialSafeModeDraupnir.client); + return await recoverAndRestart(sender, initialSafeModeDraupnir); +} + +async function testAllClobberEffects( + sender: StringUserID, + draupnir: Draupnir, + effects: ClobberEffectDescription[] +): Promise { + for (const effect of effects) { + await testClobberEffect(sender, draupnir, effect); + } +} + +export const ClobberEffects = [ + { + effect: clobberProtectedRooms, + description: "ProtectedRooms", + }, + { + effect: clobberWatchedLists, + description: "WatchedLists", + }, + { + effect: clobberEnabledProtections, + description: "EnabledProtections", + }, +]; + +export async function testRecoverAndRestart( + sender: StringUserID, + draupnir: Draupnir +): Promise { + await testAllClobberEffects(sender, draupnir, ClobberEffects); +} From 88bf45b404bc6dfa329937bf5c43b671fc2fcbd9 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Tue, 1 Oct 2024 13:40:42 +0100 Subject: [PATCH 13/17] Update to MPS 1.5.0 for PersistentConfigData features. --- package.json | 4 ++-- yarn.lock | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 2a079caa..e097f182 100644 --- a/package.json +++ b/package.json @@ -69,8 +69,8 @@ "js-yaml": "^4.1.0", "jsdom": "^24.0.0", "matrix-appservice-bridge": "^9.0.1", - "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@1.4.0", - "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@1.4.0", + "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@1.5.1", + "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@1.5.1", "parse-duration": "^1.0.2", "pg": "^8.8.0", "shell-quote": "^1.7.3", diff --git a/yarn.lock b/yarn.lock index cfa6de47..02f2cb2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2526,17 +2526,17 @@ matrix-appservice@^2.0.0: request-promise "^4.2.6" sanitize-html "^2.8.0" -"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-1.4.0.tgz#0745358810e03959344e791733e6ccb7e811689d" - integrity sha512-xGkBaBdZ3u5Z27HqG2IEPxQ8Z5slFtWCihnFzocPBtRcLDJMFXnoF1+bpWElnPomDQtw/u0gvDwSC+f4pmTqyQ== +"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-1.5.1.tgz#ff01f3c81a28b7ea7391d9df06cac0613ad61b50" + integrity sha512-cPG46fPDlROSsGPY79XZpjp6AIUBvk2rGZkPUr1Cbxl1FIntOzDNIKePLu3CEEAY4RppPDmmjsm0bmRt6zbYBA== dependencies: "@gnuxie/typescript-result" "^1.0.0" -"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-1.4.0.tgz#0cce6f9dec22cf41b8ffc26861e5ae5c7cf463db" - integrity sha512-/1+L/Tf/fJx9aeNvyIfF4dqkS6YtTkRLMvSiqFNIpCq3JxOwyM2nqVBBNG5rbVPPdrxGlaFP+GuPxAUm54SdSA== +"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-1.5.1.tgz#243068169f8ff71e1e3fe5ae61d53c08995de520" + integrity sha512-A4xaONgW4asZ22cZeZl+Rk4lWWd44WOWFOoZjRFSmve8ismU+HQQ+PA2jLacfARvlqi/puqiYAb6VN7GIbtz/w== dependencies: "@gnuxie/typescript-result" "^1.0.0" await-lock "^2.2.2" From ba6ec9bb9a3cc7a2796a8dee0924d0d73e55c558 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Tue, 1 Oct 2024 13:42:44 +0100 Subject: [PATCH 14/17] Enter safe mode by default. However, we still need to consider whether all errors should enter safe mode or only errors that we know are recoverable. --- src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 581d2d61..dba253eb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -192,7 +192,7 @@ const defaultConfig: IConfig = { }, }, safeMode: { - bootIntoOnStartupFailure: false, + bootIntoOnStartupFailure: true, }, health: { healthz: { From bd6c3c445525fd59df9a330f3fbf87cd4a8efd5f Mon Sep 17 00:00:00 2001 From: gnuxie Date: Tue, 1 Oct 2024 16:17:50 +0100 Subject: [PATCH 15/17] WebAPIs.stop/start method was accidentally and implicitly async, fixed. This was leading to integration test failures on CI, when the webserver hadn't been stopped before it was started again in tests. --- src/DraupnirBotMode.ts | 14 +++++----- src/index.ts | 4 +-- src/webapis/WebAPIs.ts | 53 +++++++++++++++++++++++++----------- test/integration/fixtures.ts | 2 +- 4 files changed, 47 insertions(+), 26 deletions(-) diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 31a9ad50..515ce202 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -209,7 +209,7 @@ export class DraupnirBotModeToggle implements BotModeTogle { await this.webAPIs.start(); } catch (e) { if (e instanceof Error) { - this.stopDraupnir(); + await this.stopDraupnir(); log.error("Failed to start webAPIs", e); return ActionException.Result("Failed to start webAPIs", { exceptionKind: ActionExceptionKind.Unknown, @@ -241,7 +241,7 @@ export class DraupnirBotModeToggle implements BotModeTogle { if (isError(safeModeResult)) { return safeModeResult; } - this.stopDraupnir(); + await this.stopDraupnir(); this.safeModeDraupnir = safeModeResult.ok; this.safeModeDraupnir.start(); if (options?.sendStatusOnStart) { @@ -292,7 +292,7 @@ export class DraupnirBotModeToggle implements BotModeTogle { await this.webAPIs.start(); await this.draupnir.startupComplete(); } catch (e) { - this.stopEverything(); + await this.stopEverything(); throw e; } } else if (this.safeModeDraupnir !== null) { @@ -300,10 +300,10 @@ export class DraupnirBotModeToggle implements BotModeTogle { } } - private stopDraupnir(): void { + private async stopDraupnir(): Promise { this.draupnir?.stop(); this.draupnir = null; - this.webAPIs?.stop(); + await this.webAPIs?.stop(); this.webAPIs = null; } @@ -312,8 +312,8 @@ export class DraupnirBotModeToggle implements BotModeTogle { this.safeModeDraupnir = null; } - public stopEverything(): void { - this.stopDraupnir(); + public async stopEverything(): Promise { + await this.stopDraupnir(); this.stopSafeModeDraupnir(); } } diff --git a/src/index.ts b/src/index.ts index b006bf26..c1ae8aee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,7 +111,7 @@ void (async function () { console.error( `Failed to setup mjolnir from the config ${config.dataPath}: ${err}` ); - bot?.stopEverything(); + await bot?.stopEverything(); throw err; } try { @@ -120,7 +120,7 @@ void (async function () { healthz.isHealthy = true; } catch (err) { console.error(`Mjolnir failed to start: ${err}`); - bot.stopEverything(); + await bot.stopEverything(); throw err; } })(); diff --git a/src/webapis/WebAPIs.ts b/src/webapis/WebAPIs.ts index 1a309f32..e6d33512 100644 --- a/src/webapis/WebAPIs.ts +++ b/src/webapis/WebAPIs.ts @@ -17,7 +17,9 @@ import { StringRoomID, StringEventID, } from "@the-draupnir-project/matrix-basic-types"; -import { Task } from "matrix-protection-suite"; +import { Logger, Task } from "matrix-protection-suite"; + +const log = new Logger("WebAPIs"); /** * A common prefix for all web-exposed APIs. @@ -41,18 +43,22 @@ export class WebAPIs { /** * Start accepting requests to the Web API. */ - public async start() { + public async start(): Promise { if (!this.config.web.enabled) { return; } - this.httpServer = this.webController.listen( - this.config.web.port, - this.config.web.address - ); - + await new Promise((resolve) => { + this.httpServer = this.webController.listen( + this.config.web.port, + this.config.web.address, + () => { + resolve(undefined); + } + ); + }); // configure /report API. if (this.config.web.abuseReporting.enabled) { - console.log(`configuring ${API_PREFIX}/report/:room_id/:event_id...`); + log.info(`configuring ${API_PREFIX}/report/:room_id/:event_id...`); this.webController.options( `${API_PREFIX}/report/:room_id/:event_id`, (request, response) => { @@ -70,7 +76,7 @@ export class WebAPIs { this.webController.post( `${API_PREFIX}/report/:room_id/:event_id`, (request, response) => { - console.debug( + log.debug( `Received a message on ${API_PREFIX}/report/:room_id/:event_id`, request.params ); @@ -91,16 +97,31 @@ export class WebAPIs { ); } ); - console.log( - `configuring ${API_PREFIX}/report/:room_id/:event_id... DONE` - ); + log.info(`configuring ${API_PREFIX}/report/:room_id/:event_id... DONE`); } } - public stop() { + public async stop(): Promise { if (this.httpServer) { - console.log("Stopping WebAPIs."); - this.httpServer.close(); + log.info("Stopping WebAPIs."); + await new Promise((resolve, reject) => { + if (this.httpServer === undefined) { + throw new TypeError("There is some kind of weird race going on here"); + } + this.httpServer.close((error) => { + if (error === undefined) { + resolve(undefined); + } else if ( + "code" in error && + error.code === "ERR_SERVER_NOT_RUNNING" + ) { + resolve(undefined); + } else { + log.error("Error when stopping WebAPIs", error); + reject(error); + } + }); + }); this.httpServer = undefined; } } @@ -226,7 +247,7 @@ export class WebAPIs { // Match the spec behavior of `/report`: return 200 and an empty JSON. response.status(200).json({}); } catch (ex) { - console.warn("Error responding to an abuse report", roomID, eventID, ex); + log.warn("Error responding to an abuse report", roomID, eventID, ex); response.status(503); } } diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts index b8a182a6..dde80671 100644 --- a/test/integration/fixtures.ts +++ b/test/integration/fixtures.ts @@ -61,7 +61,7 @@ export const mochaHooks = { afterEach: [ async function (this: DraupnirTestContext) { this.timeout(10000); - this.toggle?.stopEverything(); + await this.toggle?.stopEverything(); draupnirClient()?.stop(); // remove alias from management room and leave it. From 98c1822013282aa6c5f3b02d9ac69eb8fa1ff8f8 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Tue, 1 Oct 2024 17:05:26 +0100 Subject: [PATCH 16/17] Give some flexibility for when to enter safe mode. --- config/default.yaml | 7 +++ src/DraupnirBotMode.ts | 36 ++++++++------ src/config.ts | 5 +- .../StandardDraupnirManager.ts | 49 +++++++++++-------- src/safemode/BootOption.ts | 9 ++++ 5 files changed, 70 insertions(+), 36 deletions(-) create mode 100644 src/safemode/BootOption.ts diff --git a/config/default.yaml b/config/default.yaml index e9c001bf..03ce765d 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -211,6 +211,13 @@ protections: roomStateBackingStore: enabled: false +#safeMode: +# # The option for entering safe mode when Draupnir fails to start up. +# # - "RecoveryOnly" will only start the bot in safe mode when there are recovery options available. +# # - "Never" will never start the bot in safe mode when Draupnir fails to start normally. +# # - "Always" will always start the bot in safe mode when Draupnir fails to start normally. +# bootOption: RecoveryOnly + # Options for advanced monitoring of the health of the bot. health: # healthz options. These options are best for use in container environments diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index 515ce202..5288c810 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -18,6 +18,7 @@ import { Logger, ActionException, ActionExceptionKind, + ConfigRecoverableError, } from "matrix-protection-suite"; import { BotSDKLogServiceLogger, @@ -48,6 +49,7 @@ import { import { SafeModeDraupnir } from "./safemode/DraupnirSafeMode"; import { ResultError } from "@gnuxie/typescript-result"; import { SafeModeCause, SafeModeReason } from "./safemode/SafeModeCause"; +import { SafeModeBootOption } from "./safemode/BootOption"; setGlobalLoggerProvider(new BotSDKLogServiceLogger()); @@ -268,20 +270,26 @@ export class DraupnirBotModeToggle implements BotModeTogle { error: ResultError, options?: SafeModeToggleOptions | undefined ): Promise> { - if (this.config.safeMode?.bootIntoOnStartupFailure) { - log.error( - "Failed to start draupnir, switching to safe mode as configured", - error - ); - return await this.switchToSafeMode( - { - reason: SafeModeReason.InitializationError, - error: error, - }, - options ?? {} - ); - } else { - return Err(error); + 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 ?? {} + ); } } diff --git a/src/config.ts b/src/config.ts index dba253eb..4c5dfa2b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,7 @@ import { load } from "js-yaml"; import { MatrixClient, LogService } from "matrix-bot-sdk"; import Config from "config"; import path from "path"; +import { SafeModeBootOption } from "./safemode/BootOption"; /** * The configuration, as read from production.yaml @@ -90,7 +91,7 @@ export interface IConfig { }; }; safeMode?: { - bootIntoOnStartupFailure: boolean; + bootOption: SafeModeBootOption; }; health: { healthz: { @@ -192,7 +193,7 @@ const defaultConfig: IConfig = { }, }, safeMode: { - bootIntoOnStartupFailure: true, + bootOption: SafeModeBootOption.RecoveryOnly, }, health: { healthz: { diff --git a/src/draupnirfactory/StandardDraupnirManager.ts b/src/draupnirfactory/StandardDraupnirManager.ts index a18320d8..adb17b89 100644 --- a/src/draupnirfactory/StandardDraupnirManager.ts +++ b/src/draupnirfactory/StandardDraupnirManager.ts @@ -11,6 +11,7 @@ import { ActionError, ActionResult, + ConfigRecoverableError, Logger, isError, } from "matrix-protection-suite"; @@ -28,6 +29,7 @@ import { SafeModeToggle, } from "../safemode/SafeModeToggle"; import { ResultError } from "@gnuxie/typescript-result"; +import { SafeModeBootOption } from "../safemode/BootOption"; const log = new Logger("StandardDraupnirManager"); @@ -91,30 +93,37 @@ export class StandardDraupnirManager { draupnir.error, clientUserID ); - if (config.safeMode?.bootIntoOnStartupFailure) { - log.error( - `Failed to start draupnir ${clientUserID}, switching to safe mode as configured`, - draupnir.error - ); - const safeModeResult = await this.makeSafeModeDraupnir( - clientUserID, - managementRoom, - config, - { - reason: SafeModeReason.InitializationError, - error: draupnir.error, + switch (config.safeMode?.bootOption) { + case SafeModeBootOption.Never: + return draupnir; + case SafeModeBootOption.RecoveryOnly: + if (!(draupnir.error instanceof ConfigRecoverableError)) { + return draupnir; } - ); - if (isError(safeModeResult)) { - return safeModeResult; - } else { - return DraupnirRestartError.Result( + // fallthrough + default: { + log.error( `Failed to start draupnir ${clientUserID}, switching to safe mode as configured`, - { safeModeDraupnir: safeModeResult.ok } + draupnir.error ); + const safeModeResult = await this.makeSafeModeDraupnir( + clientUserID, + managementRoom, + config, + { + reason: SafeModeReason.InitializationError, + error: draupnir.error, + } + ); + if (isError(safeModeResult)) { + return safeModeResult; + } else { + return DraupnirRestartError.Result( + `Failed to start draupnir ${clientUserID}, switching to safe mode as configured`, + { safeModeDraupnir: safeModeResult.ok } + ); + } } - } else { - return draupnir; } } this.draupnir.set(clientUserID, draupnir.ok); diff --git a/src/safemode/BootOption.ts b/src/safemode/BootOption.ts new file mode 100644 index 00000000..7f8083db --- /dev/null +++ b/src/safemode/BootOption.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +export enum SafeModeBootOption { + RecoveryOnly = "RecoveryOnly", + AllErrors = "AllErrors", + Never = "Never", +} From d817810be96fffa3a009ee6408a2910bd3702a8d Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 2 Oct 2024 17:23:34 +0100 Subject: [PATCH 17/17] Add more detail about safe mode to the config file. --- config/default.yaml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/config/default.yaml b/config/default.yaml index 03ce765d..d4570ccc 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -211,9 +211,19 @@ protections: roomStateBackingStore: enabled: false +# Safe mode provides recovery options for some failure modes when Draupnir +# fails to start. For example, if the bot fails to resolve a room alias in +# a watched list, or if the server has parted from a protected room and can't +# find a way back in. Safe mode will provide different options to recover from +# these. Such as unprotecting the room or unwatching the policy list. +# By default Draupnir will boot into safe mode only when the failure mode +# is recoverable. +# It may be desirable to prevent the bot from starting into safe mode if you have +# a pager system when Draupnir is down, as Draupnir could prevent your monitoring +# system from identifying a failure to start. #safeMode: # # The option for entering safe mode when Draupnir fails to start up. -# # - "RecoveryOnly" will only start the bot in safe mode when there are recovery options available. +# # - "RecoveryOnly" will only start the bot in safe mode when there are recovery options available. This is the default. # # - "Never" will never start the bot in safe mode when Draupnir fails to start normally. # # - "Always" will always start the bot in safe mode when Draupnir fails to start normally. # bootOption: RecoveryOnly