├── test ├── draupnir_news.json.license ├── tsconfig.json ├── draupnir_news.json ├── tsnode.cjs ├── integration │ ├── commands │ │ ├── recoverCommandTest.ts │ │ ├── shutdownCommandTest.ts │ │ └── hijackRoomCommandTest.ts │ ├── manualLaunchScript.ts │ ├── helloTest.ts │ ├── throttleTest.ts │ ├── utilsTest.ts │ ├── httpAntispamTest.ts │ └── reportPollingTest.ts ├── unit │ ├── config │ │ └── unknownPropertiesTest.ts │ ├── protections │ │ ├── MentionLimitProtectionTest.ts │ │ └── RedactionSynchronisationTest.ts │ ├── stores │ │ ├── roomAuditLogTest.ts │ │ └── userRestrictionAuditLogTest.ts │ └── commands │ │ └── WatchUnwatchCommandTest.ts ├── appservice │ ├── utils │ │ ├── ProvisionHelper.ts │ │ ├── harness.ts │ │ └── webAPIClient.ts │ └── integration │ │ ├── listUnstartedDraupnirTest.ts │ │ ├── safeModeRecoverTest.ts │ │ ├── safeModeToggleTest.ts │ │ └── provisionTest.ts └── nginx.conf ├── src ├── protections │ ├── DraupnirNews │ │ ├── news.json.license │ │ └── news.json │ ├── RoomTakedown │ │ ├── SynapseAdminRoomDetails.ts │ │ ├── RoomAuditLog.ts │ │ ├── RoomDiscoveryRenderer.tsx │ │ └── RoomDiscovery.ts │ ├── Protection.ts │ ├── invitation │ │ └── inviteCore.ts │ ├── HomeserverUserPolicyApplication │ │ ├── UserRestrictionAuditLog.ts │ │ ├── UserRestrictionCapability.ts │ │ └── deactivateUser.ts │ ├── DraupnirProtectionsIndex.ts │ ├── ConfigHooks.ts │ ├── ProtectedRoomsSetRenderers.tsx │ ├── ConfigMigration │ │ └── CapabilitySetProviderMigration.ts │ └── ProtectedRooms │ │ └── UnprotectPartedRooms.tsx ├── safemode │ ├── BootOption.ts │ ├── SafeModeCause.ts │ ├── commands │ │ ├── RestartDraupnirCommand.ts │ │ ├── SafeModeAdaptor.ts │ │ ├── HelpCommand.tsx │ │ └── SafeModeCommands.tsx │ ├── SafeModeToggle.ts │ ├── RecoveryOptions.tsx │ ├── SafeModeCommandDispatcher.ts │ └── ManagementRoom.ts ├── appservice │ ├── postgres │ │ ├── schema │ │ │ ├── v2.ts │ │ │ └── v1.ts │ │ └── PgDataStore.ts │ ├── config │ │ ├── REUSE.toml │ │ ├── config.harness.yaml │ │ └── config.example.yaml │ ├── bot │ │ ├── AppserviceBotCommandTable.ts │ │ ├── AppserviceBotPrerequisite.ts │ │ ├── AppserviceBotCommands.ts │ │ ├── AppserviceBotInterfaceAdaptor.ts │ │ ├── AppserviceBotHelp.tsx │ │ ├── AccessCommands.tsx │ │ ├── AppserviceBotCommandDispatcher.ts │ │ └── ListCommand.tsx │ ├── datastore.ts │ └── cli.ts ├── capabilities │ ├── capabilityIndex.ts │ ├── RoomTakedownCapability.tsx │ ├── RendererMessageCollector.ts │ ├── DraupnirRendererMessageCollector.tsx │ └── StandardEventConsequencesRenderer.tsx ├── webapis │ └── SynapseHTTPAntispam │ │ ├── PingEndpoint.ts │ │ ├── CheckEventForSpamEndpoint.ts │ │ ├── UserMayJoinRoomEndpoint.ts │ │ ├── SpamCheckEndpointPluginManager.ts │ │ ├── UserMayInviteEndpoint.ts │ │ └── SynapseHttpAntispam.ts ├── commands │ ├── DraupnirCommandTable.ts │ ├── DraupnirCommandPrerequisites.ts │ ├── SafeModeCommand.ts │ ├── SetDisplayNameCommand.ts │ ├── Help.tsx │ ├── ResolveAlias.tsx │ ├── server-admin │ │ ├── UnrestrictCommand.tsx │ │ ├── HijackRoomCommand.ts │ │ └── ShutdownRoomCommand.ts │ ├── CreateBanListCommand.ts │ ├── SetPowerLevelCommand.ts │ ├── unban │ │ └── UnbanEntity.tsx │ └── DraupnirCommandDispatcher.ts ├── health │ └── healthz.ts ├── managementroom │ └── ManagementRoomDetail.ts ├── queues │ ├── UnlistedUserRedactionQueue.ts │ ├── ProtectedRoomActivityTracker.ts │ ├── LeakyBucket.ts │ └── TimelineRedactionQueue.ts └── backingstore │ └── DraupnirStores.ts ├── docs ├── ban-command-prompt.gif ├── ban-propagation-prompt.gif ├── appservice.md ├── setup_docker.md ├── setup_selfbuild.md ├── synapse_module.md ├── setup.md ├── code-style.md ├── triaging.md ├── development.md ├── context.md ├── moderators.md ├── development-environment.md └── REUSE.toml ├── .dockerignore ├── .gitattributes ├── .git-blame-ignore-revs ├── .github ├── renovate.json5 ├── ISSUE_TEMPLATE │ └── bug-report.md └── workflows │ ├── docker-hub-develop.yml │ ├── docker-hub-latest.yml │ ├── docker-hub-release.yml │ ├── mjolnir.yml │ └── sign-off.yml ├── .prettierrc.yaml ├── CONTRIBUTING.md ├── config └── REUSE.toml ├── .editorconfig ├── NOTICE ├── tsconfig.json ├── draupnir-entrypoint.sh ├── Dockerfile ├── .pre-commit-config.yaml ├── REUSE.toml ├── .gitignore └── mx-tester.yml /test/draupnir_news.json.license: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Gnuxie 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["."], 4 | "compilerOptions": { 5 | "noEmit": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/protections/DraupnirNews/news.json.license: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Gnuxie 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | -------------------------------------------------------------------------------- /docs/ban-command-prompt.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:6d3506f39bd50b3e986ba272b14c0c8cc77094f2fe75ab88cca6b3144d63ec5a 3 | size 288619 4 | -------------------------------------------------------------------------------- /src/protections/RoomTakedown/SynapseAdminRoomDetails.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | -------------------------------------------------------------------------------- /docs/ban-propagation-prompt.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0d23fd7a118a4db0ef60e44c70f3c6d258d830cdb01ebb3496cd2971a4ef0e76 3 | size 415683 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Gnuxie 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | node_modules 6 | config 7 | lib 8 | logs 9 | storage 10 | db 11 | *.db 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Aminda Suomalainen 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | * text=auto eol=lf 6 | *.gif filter=lfs diff=lfs merge=lfs -text 7 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Gnuxie 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | # The first time that we ran prettier. 5 | 3b2036c2db205e7f9a10a6b4fef2ec1760b8f51b 6 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Aminda Suomalainen 2 | // 3 | // SPDX-License-Identifier: CC0-1.0 4 | { 5 | extends: ["github>the-draupnir-project/.github:renovate-shared"], 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Gnuxie 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | tabWidth: 2 6 | useTabs: false 7 | semi: true 8 | trailingComma: "es5" 9 | proseWrap: always 10 | -------------------------------------------------------------------------------- /docs/appservice.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | This Document has moved to 8 | https://the-draupnir-project.github.io/draupnir-documentation/appservice 9 | -------------------------------------------------------------------------------- /docs/setup_docker.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | This document has moved to 8 | https://the-draupnir-project.github.io/draupnir-documentation/bot/setup_docker 9 | -------------------------------------------------------------------------------- /docs/setup_selfbuild.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | This Document has moved to 8 | https://the-draupnir-project.github.io/draupnir-documentation/bot/setup_selfbuild 9 | -------------------------------------------------------------------------------- /docs/synapse_module.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | This Document has moved to 8 | https://the-draupnir-project.github.io/draupnir-documentation/bot/synapse_module 9 | -------------------------------------------------------------------------------- /docs/setup.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Setting up Draupnir 8 | 9 | This Document has moved to 10 | https://the-draupnir-project.github.io/draupnir-documentation/bot/setup 11 | -------------------------------------------------------------------------------- /src/safemode/BootOption.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | export enum SafeModeBootOption { 6 | RecoveryOnly = "RecoveryOnly", 7 | AllErrors = "AllErrors", 8 | Never = "Never", 9 | } 10 | -------------------------------------------------------------------------------- /docs/code-style.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Code style 8 | 9 | This Document has moved to 10 | https://the-draupnir-project.github.io/draupnir-documentation/contributing/code-style 11 | -------------------------------------------------------------------------------- /docs/triaging.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Triaging issues 8 | 9 | This Document has moved to 10 | https://the-draupnir-project.github.io/draupnir-documentation/contributing/triaging 11 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Developing Draupnir 8 | 9 | This Document has moved to 10 | https://the-draupnir-project.github.io/draupnir-documentation/contributing/development 11 | -------------------------------------------------------------------------------- /docs/context.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## Context for developing Draupnir 8 | 9 | This Document has moved to 10 | https://the-draupnir-project.github.io/draupnir-documentation/contributing/context 11 | -------------------------------------------------------------------------------- /docs/moderators.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Moderator's guide to Draupnir (bot edition) 8 | 9 | This Document has moved to 10 | https://the-draupnir-project.github.io/draupnir-documentation/bot/moderators 11 | -------------------------------------------------------------------------------- /docs/development-environment.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Developing Draupnir - tests, tools, and environment 8 | 9 | This Document has moved to 10 | https://the-draupnir-project.github.io/draupnir-documentation/contributing/development-environment 11 | -------------------------------------------------------------------------------- /src/appservice/postgres/schema/v2.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: CC0-1.0 4 | 5 | import postgres from "postgres"; 6 | 7 | export async function runSchema(sql: postgres.Sql) { 8 | await sql.begin((s) => [ 9 | s`ALTER TABLE IF EXISTS mjolnir RENAME TO draupnir;`, 10 | ]); 11 | } 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Contributing to Draupnir 8 | 9 | Our contributing guidelines can be found as part of our documentation. 10 | [This link](https://the-draupnir-project.github.io/draupnir-documentation/contributing) 11 | leads to the contributing guidelines. 12 | -------------------------------------------------------------------------------- /config/REUSE.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[annotations]] 4 | path = "*.yaml" 5 | precedence = "aggregate" 6 | SPDX-FileCopyrightText = "Gnuxie " 7 | SPDX-License-Identifier = "CC0-1.0" 8 | 9 | [[annotations]] 10 | path = "REUSE.toml" 11 | precedence = "aggregate" 12 | SPDX-FileCopyrightText = "Gnuxie " 13 | SPDX-License-Identifier = "CC0-1.0" 14 | -------------------------------------------------------------------------------- /docs/REUSE.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[annotations]] 4 | path = "*.gif" 5 | precedence = "aggregate" 6 | SPDX-FileCopyrightText = "Gnuxie " 7 | SPDX-License-Identifier = "CC0-1.0" 8 | 9 | [[annotations]] 10 | path = "REUSE.toml" 11 | precedence = "aggregate" 12 | SPDX-FileCopyrightText = "Gnuxie " 13 | SPDX-License-Identifier = "CC0-1.0" 14 | -------------------------------------------------------------------------------- /src/appservice/config/REUSE.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[annotations]] 4 | path = "*.yaml" 5 | precedence = "aggregate" 6 | SPDX-FileCopyrightText = "Gnuxie " 7 | SPDX-License-Identifier = "CC0-1.0" 8 | 9 | [[annotations]] 10 | path = "REUSE.toml" 11 | precedence = "aggregate" 12 | SPDX-FileCopyrightText = "Gnuxie " 13 | SPDX-License-Identifier = "CC0-1.0" 14 | -------------------------------------------------------------------------------- /src/appservice/postgres/schema/v1.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: CC0-1.0 4 | 5 | import postgres from "postgres"; 6 | 7 | export async function runSchema(sql: postgres.Sql) { 8 | await sql.begin((s) => [ 9 | s`CREATE TABLE mjolnir (local_part VARCHAR(255), owner VARCHAR(255), management_room TEXT);`, 10 | ]); 11 | } 12 | -------------------------------------------------------------------------------- /test/draupnir_news.json: -------------------------------------------------------------------------------- 1 | { 2 | "news": [ 3 | { 4 | "news_id": "59e0dd6e-87da-4459-98ae-627c0f2a7d8b", 5 | "matrix_event_content": { 6 | "body": "Announcing the Draupnir Longhouse Assembly! https://matrix.to/#/!DtwZFWORUIApKsOVWi:matrix.org/%24GdBN1XqoOnAfc5tJgxhoXNoAdW2YUbS1Mtsb8LbzIJ4?via=matrix.org&via=feline.support&via=asgard.chat", 7 | "msgtype": "m.notice" 8 | } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Aminda Suomalainen 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | charset = utf-8 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [{LICENSE,NOTICE,*.{md,yml,yaml,json,txt}}] 16 | trim_trailing_whitespace = false 17 | indent_style = space 18 | indent_size = unset 19 | -------------------------------------------------------------------------------- /src/appservice/bot/AppserviceBotCommandTable.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | // 5 | // SPDX-FileAttributionText: 6 | // This modified file incorporates work from Draupnir 7 | // https://github.com/the-draupnir-project/Draupnir 8 | // 9 | 10 | import { StandardCommandTable } from "@the-draupnir-project/interface-manager"; 11 | 12 | export const AppserviceBotCommands = new StandardCommandTable("admin"); 13 | -------------------------------------------------------------------------------- /src/appservice/config/config.harness.yaml: -------------------------------------------------------------------------------- 1 | homeserver: 2 | domain: "localhost:9999" 3 | url: http://localhost:8081 4 | 5 | db: 6 | engine: "postgres" 7 | connectionString: "postgres://mjolnir-tester:mjolnir-test@127.0.0.1:8083/mjolnir-test-db" 8 | 9 | adminRoom: "#draupnir-admin:localhost:9999" 10 | 11 | webAPI: 12 | port: 9001 13 | 14 | # The directory the bot should store various bits of information in 15 | dataPath: "./test/harness/mjolnir-data/" 16 | 17 | roomStateBackingStore: 18 | enabled: false 19 | -------------------------------------------------------------------------------- /src/protections/Protection.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // Copyright 2019 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { Protection } from "matrix-protection-suite"; 12 | 13 | export interface DraupnirProtection 14 | extends Protection {} 15 | -------------------------------------------------------------------------------- /test/tsnode.cjs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: CC0-1.0 4 | 5 | const tsAutoMockTransformer = require("ts-auto-mock/transformer").default; 6 | require("ts-node").register({ 7 | project: "./tsconfig.json", 8 | transformers: (program) => ({ 9 | before: [tsAutoMockTransformer(program)], 10 | }), 11 | }); 12 | 13 | // Mocha apparently suppresses unhandled rejections for some crazy reason?? 14 | process.on("unhandledRejection", (reason) => { 15 | throw reason; 16 | }); 17 | -------------------------------------------------------------------------------- /src/safemode/SafeModeCause.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { ResultError } from "@gnuxie/typescript-result"; 6 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; 7 | 8 | export enum SafeModeReason { 9 | InitializationError = "InitializationError", 10 | ByRequest = "ByRequest", 11 | } 12 | 13 | export type SafeModeCause = 14 | | { reason: SafeModeReason.ByRequest; user: StringUserID } 15 | | { reason: SafeModeReason.InitializationError; error: ResultError }; 16 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | This project incorporates modified work from: 2 | 3 | - mjolnir https://github.com/matrix-org/mjolnir 4 | 5 | Any source file incorporating work retains copyright notices. 6 | All source files include an SPDX license identifier. 7 | Licenses relating to the identifiers can be found in the LICENSES directory. 8 | Any source file incorporating work includes an attribution notice to indicate modification and origin. 9 | These attribution notices use the SPDX file attribution text field and must be 10 | respected as attribution notices to be reproduced in all copies or derivatives of the source file. 11 | -------------------------------------------------------------------------------- /src/appservice/bot/AppserviceBotPrerequisite.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | // 5 | // SPDX-FileAttributionText: 6 | // This modified file incorporates work from Draupnir 7 | // https://github.com/the-draupnir-project/Draupnir 8 | // 9 | 10 | import { MatrixAdaptorContext } from "@the-draupnir-project/mps-interface-adaptor"; 11 | import { MjolnirAppService } from "../AppService"; 12 | import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; 13 | 14 | export interface AppserviceAdaptorContext extends MatrixAdaptorContext { 15 | appservice: MjolnirAppService; 16 | client: MatrixSendClient; 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "esModuleInterop": true, 5 | "exactOptionalPropertyTypes": true, 6 | "experimentalDecorators": true, 7 | "emitDecoratorMetadata": true, 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "newLine": "LF", 11 | "noImplicitAny": true, 12 | "noImplicitReturns": true, 13 | "noImplicitThis": true, 14 | "noUncheckedIndexedAccess": true, 15 | "noUnusedLocals": true, 16 | "outDir": "./lib", 17 | "sourceMap": true, 18 | "strictNullChecks": true, 19 | "strictPropertyInitialization": true, 20 | "target": "es2021", 21 | "types": ["node", "mocha"], 22 | "jsx": "react", 23 | "jsxFactory": "DeadDocumentJSX.JSXFactory" 24 | }, 25 | "include": ["./src/**/*"] 26 | } 27 | -------------------------------------------------------------------------------- /src/protections/RoomTakedown/RoomAuditLog.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | import { Result } from "@gnuxie/typescript-result"; 6 | import { 7 | StringEventID, 8 | StringRoomID, 9 | } from "@the-draupnir-project/matrix-basic-types"; 10 | import { LiteralPolicyRule, RoomBasicDetails } from "matrix-protection-suite"; 11 | 12 | export type RoomTakedownDetails = Omit & { 13 | policy_id: StringEventID; 14 | created_at: number; 15 | }; 16 | 17 | export interface RoomAuditLog { 18 | takedownRoom( 19 | policy: LiteralPolicyRule, 20 | details: RoomBasicDetails 21 | ): Promise>; 22 | isRoomTakendown(roomID: StringRoomID): boolean; 23 | getTakedownDetails( 24 | roomID: StringRoomID 25 | ): Promise>; 26 | destroy(): void; 27 | } 28 | -------------------------------------------------------------------------------- /src/appservice/config/config.example.yaml: -------------------------------------------------------------------------------- 1 | homeserver: 2 | # The Matrix server name, this will be the name of the server in your matrix id. 3 | domain: "localhost:9999" 4 | # The url for the appservice to call the client server API from. 5 | url: http://localhost:8081 6 | 7 | # Database configuration for storing which Draupnirs have been provisioned. 8 | db: 9 | engine: "postgres" 10 | connectionString: "postgres://mjolnir-tester:mjolnir-test@localhost:8083/mjolnir-test-db" 11 | 12 | # A room you have created that scopes who can access the appservice. 13 | # See docs/access_control.md 14 | adminRoom: "#draupnir-admin:localhost:9999" 15 | 16 | # This is a web api that the widget connects to in order to interact with the appservice. 17 | webAPI: 18 | port: 9001 19 | 20 | # The directory the bot should store various bits of information in 21 | dataPath: "/data/storage" 22 | 23 | roomStateBackingStore: 24 | enabled: false 25 | -------------------------------------------------------------------------------- /src/capabilities/capabilityIndex.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022-2024 Gnuxie 2 | // Copyright 2019 2021 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import "../protections/HomeserverUserPolicyApplication/UserRestrictionCapability"; 12 | import "../protections/HomeserverUserPolicyApplication/UserRestrictionCapabilityRenderer"; 13 | import "../protections/HomeserverUserPolicyApplication/UserSuspensionCapability"; 14 | import "./StandardEventConsequencesRenderer"; 15 | import "./ServerACLConsequencesRenderer"; 16 | import "./StandardUserConsequencesRenderer"; 17 | import "./RoomTakedownCapability"; 18 | import "./RoomTakedownCapabilityRenderer"; 19 | import "./SynapseAdminRoomTakedown/SynapseAdminRoomTakedown"; 20 | -------------------------------------------------------------------------------- /src/safemode/commands/RestartDraupnirCommand.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { describeCommand } from "@the-draupnir-project/interface-manager"; 6 | import { Draupnir } from "../../Draupnir"; 7 | import { SafeModeDraupnir } from "../DraupnirSafeMode"; 8 | import { Result } from "@gnuxie/typescript-result"; 9 | import { SafeModeInterfaceAdaptor } from "./SafeModeAdaptor"; 10 | 11 | export const SafeModeRestartCommand = describeCommand({ 12 | summary: "Restart Draupnir, quitting safe mode.", 13 | parameters: [], 14 | async executor({ 15 | safeModeToggle, 16 | }: SafeModeDraupnir): Promise> { 17 | return safeModeToggle.switchToDraupnir({ sendStatusOnStart: true }); 18 | }, 19 | }); 20 | 21 | SafeModeInterfaceAdaptor.describeRenderer(SafeModeRestartCommand, { 22 | isAlwaysSupposedToUseDefaultRenderer: true, 23 | }); 24 | -------------------------------------------------------------------------------- /src/protections/invitation/inviteCore.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; 6 | import { 7 | Membership, 8 | MembershipEvent, 9 | RoomMembershipRevision, 10 | } from "matrix-protection-suite"; 11 | 12 | export function isInvitationForUser( 13 | event: MembershipEvent, 14 | clientUserID: StringUserID 15 | ): event is MembershipEvent & { content: { membership: Membership.Invite } } { 16 | return ( 17 | event.state_key === clientUserID && 18 | event.content.membership === Membership.Invite 19 | ); 20 | } 21 | 22 | export function isSenderJoinedInRevision( 23 | senderUserID: StringUserID, 24 | membership: RoomMembershipRevision 25 | ): boolean { 26 | const senderMembership = membership.membershipForUser(senderUserID); 27 | return Boolean(senderMembership?.content.membership === Membership.Join); 28 | } 29 | -------------------------------------------------------------------------------- /draupnir-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # SPDX-FileCopyrightText: 2024 Gnuxie 4 | # SPDX-FileCopyrightText: 2022 The Matrix.org Foundation C.I.C. 5 | # 6 | # SPDX-License-Identifier: Apache-2.0 AND AFL-3.0 7 | 8 | # This is used as the entrypoint in the draupnir Dockerfile. 9 | # We want to transition away form people running the image without specifying `bot` or `appservice`. 10 | # So if eventually cli arguments are provided for the bot version, we want this to be the opportunity to move to `bot`. 11 | # Therefore using arguments without specifying `bot` (or appservice) is unsupported. 12 | # We maintain the behaviour where if it looks like someone is providing an executable to `docker run`, then we will execute that instead. 13 | # This aids configuration and debugging of the image if for example node needed to be started via another method. 14 | case "$1" in 15 | bot) shift; set -- node /draupnir/index.js "$@";; 16 | appservice) shift; set -- node /draupnir/appservice/cli.js "$@";; 17 | esac 18 | 19 | exec "$@"; 20 | -------------------------------------------------------------------------------- /src/safemode/commands/SafeModeAdaptor.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { 6 | StandardAdaptorContextToCommandContextTranslator, 7 | StandardMatrixInterfaceAdaptor, 8 | } from "@the-draupnir-project/interface-manager"; 9 | import { 10 | MatrixEventContext, 11 | invocationInformationFromMatrixEventcontext, 12 | MPSMatrixInterfaceAdaptorCallbacks, 13 | MPSCommandDispatcherCallbacks, 14 | } from "@the-draupnir-project/mps-interface-adaptor"; 15 | import { SafeModeDraupnir } from "../DraupnirSafeMode"; 16 | 17 | export const SafeModeContextToCommandContextTranslator = 18 | new StandardAdaptorContextToCommandContextTranslator(); 19 | 20 | export const SafeModeInterfaceAdaptor = new StandardMatrixInterfaceAdaptor< 21 | SafeModeDraupnir, 22 | MatrixEventContext 23 | >( 24 | SafeModeContextToCommandContextTranslator, 25 | invocationInformationFromMatrixEventcontext, 26 | MPSMatrixInterfaceAdaptorCallbacks, 27 | MPSCommandDispatcherCallbacks 28 | ); 29 | -------------------------------------------------------------------------------- /src/protections/HomeserverUserPolicyApplication/UserRestrictionAuditLog.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { Result } from "@gnuxie/typescript-result"; 6 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; 7 | import { LiteralPolicyRule } from "matrix-protection-suite"; 8 | import { AccountRestriction } from "matrix-protection-suite-for-matrix-bot-sdk"; 9 | 10 | export interface UserRestrictionAuditLog { 11 | isUserRestricted(userID: StringUserID): Promise>; 12 | recordUserRestriction( 13 | userID: StringUserID, 14 | restriction: AccountRestriction, 15 | options: { 16 | rule: LiteralPolicyRule | null; 17 | sender: StringUserID; 18 | } 19 | ): Promise>; 20 | recordExistingUserRestriction( 21 | uesrID: StringUserID, 22 | restriction: AccountRestriction 23 | ): Promise>; 24 | unrestrictUser( 25 | userID: StringUserID, 26 | sender: StringUserID 27 | ): Promise>; 28 | destroy(): void; 29 | } 30 | -------------------------------------------------------------------------------- /src/appservice/bot/AppserviceBotCommands.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | // 5 | // SPDX-FileAttributionText: 6 | // This modified file incorporates work from Draupnir 7 | // https://github.com/the-draupnir-project/Draupnir 8 | // 9 | 10 | import { 11 | AppserviceAllowCommand, 12 | AppserviceRemoveCommand, 13 | } from "./AccessCommands"; 14 | import { AppserviceBotCommands } from "./AppserviceBotCommandTable"; 15 | import { AppserviceBotHelpCommand } from "./AppserviceBotHelp"; 16 | import { 17 | AppserviceListUnstartedCommand, 18 | AppserviceRestartDraupnirCommand, 19 | } from "./ListCommand"; 20 | 21 | AppserviceBotCommands.internCommand(AppserviceBotHelpCommand, ["admin", "help"]) 22 | .internCommand(AppserviceAllowCommand, ["admin", "allow"]) 23 | .internCommand(AppserviceRemoveCommand, ["admin", "remove"]) 24 | .internCommand(AppserviceRestartDraupnirCommand, ["admin", "restart"]) 25 | .internCommand(AppserviceListUnstartedCommand, [ 26 | "admin", 27 | "list", 28 | "unstarted", 29 | ]); 30 | -------------------------------------------------------------------------------- /test/integration/commands/recoverCommandTest.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; 6 | import { newTestUser } from "../clientHelper"; 7 | import { DraupnirTestContext, draupnir } from "../mjolnirSetupUtils"; 8 | import { testRecoverAndRestart } from "./recoverCommandDetail"; 9 | 10 | describe("We should be able to restart and recover draupnir when it has bad account data", function () { 11 | it("Recovering protected rooms", async function (this: DraupnirTestContext) { 12 | const moderator = await newTestUser(this.config.homeserverUrl, { 13 | name: { contains: "moderator" }, 14 | }); 15 | await draupnir().client.inviteUser( 16 | await moderator.getUserId(), 17 | draupnir().managementRoomID 18 | ); 19 | await moderator.joinRoom(draupnir().managementRoomID); 20 | await testRecoverAndRestart( 21 | (await moderator.getUserId()) as StringUserID, 22 | draupnir() 23 | ); 24 | } as unknown as Mocha.AsyncFunc); 25 | }); 26 | -------------------------------------------------------------------------------- /src/protections/DraupnirProtectionsIndex.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | /** 6 | * This file exists as a way to register all protections. 7 | * In future, we should maybe try to dogfood the dynamic plugin load sytem 8 | * instead. For now that system doesn't even exist. 9 | */ 10 | 11 | // import capability renderers and glue too. 12 | import "../capabilities/capabilityIndex"; 13 | 14 | // keep alphabetical please. 15 | import "./BanPropagation"; 16 | import "./BasicFlooding"; 17 | import "./DraupnirNews/DraupnirNews"; 18 | import "./FirstMessageIsImage"; 19 | import "./HomeserverUserPolicyApplication/HomeserverUserPolicyProtection"; 20 | import "./InvalidEventProtection"; 21 | import "./JoinWaveShortCircuit"; 22 | import "./RedactionSynchronisation"; 23 | import "./MembershipChangeProtection"; 24 | import "./MentionLimitProtection"; 25 | import "./MessageIsMedia"; 26 | import "./MessageIsVoice"; 27 | import "./NewJoinerProtection"; 28 | import "./PolicyChangeNotification"; 29 | import "./ProtectedRooms/RoomsSetBehaviourProtection"; 30 | import "./TrustedReporters"; 31 | import "./WordList"; 32 | -------------------------------------------------------------------------------- /src/webapis/SynapseHTTPAntispam/PingEndpoint.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | // 5 | // SPDX-FileAttributionText: 6 | // This modified file incorporates work from Draupnir 7 | // https://github.com/the-draupnir-project/Draupnir 8 | // 9 | 10 | import { Type } from "@sinclair/typebox"; 11 | import { Request, Response } from "express"; 12 | import { isError, Logger, Value } from "matrix-protection-suite"; 13 | 14 | const log = new Logger("PingEndpoint"); 15 | 16 | const PingBody = Type.Object({ 17 | id: Type.Unknown(), 18 | }); 19 | 20 | export function handleHttpAntispamPing( 21 | request: Request, 22 | response: Response 23 | ): void { 24 | const decodedBody = Value.Decode(PingBody, request.body); 25 | if (isError(decodedBody)) { 26 | log.error("Error decoding request body:", decodedBody.error); 27 | response.status(400).send({ 28 | errcode: "M_INVALID_PARAM", 29 | error: "Error decoding request body", 30 | }); 31 | return; 32 | } 33 | response.status(200).send({ 34 | id: decodedBody.ok.id, 35 | status: "ok", 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /src/commands/DraupnirCommandTable.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | // 5 | // SPDX-FileAttributionText: 6 | // This modified file incorporates work from Draupnir 7 | // https://github.com/the-draupnir-project/Draupnir 8 | // 9 | 10 | import { 11 | StandardCommandTable, 12 | StringFromMatrixEventReferenceTranslator, 13 | StringFromMatrixRoomAliasTranslator, 14 | StringFromMatrixRoomIDTranslator, 15 | StringFromMatrixUserIDTranslator, 16 | StringFromNumberTranslator, 17 | StringfromBooleanTranslator, 18 | } from "@the-draupnir-project/interface-manager"; 19 | 20 | export const DraupnirTopLevelCommands = new StandardCommandTable( 21 | "draupnir top level" 22 | ) 23 | .internPresentationTypeTranslator(StringFromNumberTranslator) 24 | .internPresentationTypeTranslator(StringfromBooleanTranslator) 25 | .internPresentationTypeTranslator(StringFromMatrixRoomIDTranslator) 26 | .internPresentationTypeTranslator(StringFromMatrixRoomAliasTranslator) 27 | .internPresentationTypeTranslator(StringFromMatrixUserIDTranslator) 28 | .internPresentationTypeTranslator(StringFromMatrixEventReferenceTranslator); 29 | -------------------------------------------------------------------------------- /src/commands/DraupnirCommandPrerequisites.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | // 5 | // SPDX-FileAttributionText: 6 | // This modified file incorporates work from Draupnir 7 | // https://github.com/the-draupnir-project/Draupnir 8 | // 9 | 10 | import { 11 | StandardAdaptorContextToCommandContextTranslator, 12 | StandardMatrixInterfaceAdaptor, 13 | } from "@the-draupnir-project/interface-manager"; 14 | import { Draupnir } from "../Draupnir"; 15 | import { 16 | MPSCommandDispatcherCallbacks, 17 | MPSMatrixInterfaceAdaptorCallbacks, 18 | MatrixEventContext, 19 | invocationInformationFromMatrixEventcontext, 20 | } from "@the-draupnir-project/mps-interface-adaptor"; 21 | 22 | export const DraupnirContextToCommandContextTranslator = 23 | new StandardAdaptorContextToCommandContextTranslator(); 24 | export const DraupnirInterfaceAdaptor = new StandardMatrixInterfaceAdaptor< 25 | Draupnir, 26 | MatrixEventContext 27 | >( 28 | DraupnirContextToCommandContextTranslator, 29 | invocationInformationFromMatrixEventcontext, 30 | MPSMatrixInterfaceAdaptorCallbacks, 31 | MPSCommandDispatcherCallbacks 32 | ); 33 | -------------------------------------------------------------------------------- /src/commands/SafeModeCommand.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { 6 | BasicInvocationInformation, 7 | describeCommand, 8 | } from "@the-draupnir-project/interface-manager"; 9 | import { SafeModeDraupnir } from "../safemode/DraupnirSafeMode"; 10 | import { Result } from "@gnuxie/typescript-result"; 11 | import { Draupnir } from "../Draupnir"; 12 | import { SafeModeReason } from "../safemode/SafeModeCause"; 13 | import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites"; 14 | 15 | export const DraupnirSafeModeCommand = describeCommand({ 16 | summary: "Enter into safe mode.", 17 | parameters: [], 18 | async executor( 19 | { safeModeToggle }: Draupnir, 20 | info: BasicInvocationInformation 21 | ): Promise> { 22 | return safeModeToggle.switchToSafeMode( 23 | { 24 | reason: SafeModeReason.ByRequest, 25 | user: info.commandSender, 26 | }, 27 | { sendStatusOnStart: true } 28 | ); 29 | }, 30 | }); 31 | 32 | DraupnirInterfaceAdaptor.describeRenderer(DraupnirSafeModeCommand, { 33 | isAlwaysSupposedToUseDefaultRenderer: true, 34 | }); 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Gnuxie 2 | # Copyright 2019 The Matrix.org Foundation C.I.C. 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 AND AFL-3.0 5 | 6 | FROM node:20-slim as build-stage 7 | RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* 8 | COPY . /tmp/src 9 | # describe the version. 10 | RUN cd /tmp/src && git describe > version.txt.tmp && mv version.txt.tmp version.txt 11 | # build and install 12 | RUN cd /tmp/src \ 13 | && yarn install --frozen-lockfile --network-timeout 100000 \ 14 | && yarn build \ 15 | && yarn install --frozen-lockfile --production --network-timeout 100000 16 | 17 | FROM node:20-slim as final-stage 18 | COPY --from=build-stage /tmp/src/version.txt version.txt 19 | COPY --from=build-stage /tmp/src/lib/ /draupnir/ 20 | COPY --from=build-stage /tmp/src/node_modules /node_modules 21 | COPY --from=build-stage /tmp/src/draupnir-entrypoint.sh / 22 | COPY --from=build-stage /tmp/src/package.json / 23 | 24 | ENV NODE_ENV=production 25 | ENV NODE_CONFIG_DIR=/data/config 26 | # Set SQLite's temporary directory. See #746 for context. 27 | ENV SQLITE_TMPDIR=/data 28 | 29 | CMD ["bot"] 30 | ENTRYPOINT ["./draupnir-entrypoint.sh"] 31 | VOLUME ["/data"] 32 | -------------------------------------------------------------------------------- /src/protections/RoomTakedown/RoomDiscoveryRenderer.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { DocumentNode } from "@the-draupnir-project/interface-manager"; 6 | import { RoomBasicDetails } from "matrix-protection-suite"; 7 | import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager"; 8 | 9 | export function renderDiscoveredRoom(details: RoomBasicDetails): DocumentNode { 10 | return ( 11 | 12 |

Room Discovered

13 |
14 | 15 | {details.room_id} 16 | 17 |
    18 |
  • 19 | name: {details.name ?? "Unamed room"} 20 |
  • 21 |
  • 22 | member count: {details.joined_members ?? "unknown"} 23 |
  • 24 |
  • 25 | room ID: {details.room_id} 26 |
  • 27 |
  • 28 | creator: {details.creator ?? "unknown"} 29 |
  • 30 |
  • 31 | topic:
    {details.topic ?? "unknown"}
    32 |
  • 33 |
34 |
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/appservice/bot/AppserviceBotInterfaceAdaptor.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | // 5 | // SPDX-FileAttributionText: 6 | // This modified file incorporates work from Draupnir 7 | // https://github.com/the-draupnir-project/Draupnir 8 | // 9 | 10 | import { 11 | StandardAdaptorContextToCommandContextTranslator, 12 | StandardMatrixInterfaceAdaptor, 13 | } from "@the-draupnir-project/interface-manager"; 14 | import { AppserviceAdaptorContext } from "./AppserviceBotPrerequisite"; 15 | import { 16 | invocationInformationFromMatrixEventcontext, 17 | MatrixEventContext, 18 | MPSCommandDispatcherCallbacks, 19 | MPSMatrixInterfaceAdaptorCallbacks, 20 | } from "@the-draupnir-project/mps-interface-adaptor"; 21 | 22 | export const AppserviceAdaptorContextToCommandContextTranslator = 23 | new StandardAdaptorContextToCommandContextTranslator(); 24 | 25 | export const AppserviceBotInterfaceAdaptor = new StandardMatrixInterfaceAdaptor< 26 | AppserviceAdaptorContext, 27 | MatrixEventContext 28 | >( 29 | AppserviceAdaptorContextToCommandContextTranslator, 30 | invocationInformationFromMatrixEventcontext, 31 | MPSMatrixInterfaceAdaptorCallbacks, 32 | MPSCommandDispatcherCallbacks 33 | ); 34 | -------------------------------------------------------------------------------- /test/integration/manualLaunchScript.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 - 2024 Gnuxie 2 | // Copyright 2021 - 2022 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | /** 12 | * This file is used to launch mjolnir for manual testing, creating a user and management room automatically if it doesn't already exist. 13 | */ 14 | 15 | import { draupnirClient, makeBotModeToggle } from "./mjolnirSetupUtils"; 16 | import { configRead, getStoragePath } from "../../src/config"; 17 | import { DefaultEventDecoder } from "matrix-protection-suite"; 18 | import { makeTopLevelStores } from "../../src/backingstore/DraupnirStores"; 19 | 20 | void (async () => { 21 | const config = configRead(); 22 | const storagePath = getStoragePath(config.dataPath); 23 | const toggle = await makeBotModeToggle(config, { 24 | stores: makeTopLevelStores(storagePath, DefaultEventDecoder, { 25 | isRoomStateBackingStoreEnabled: 26 | config.roomStateBackingStore.enabled ?? false, 27 | }), 28 | allowSafeMode: true, 29 | }); 30 | await draupnirClient()?.start(); 31 | await toggle.encryptionInitialized(); 32 | })(); 33 | -------------------------------------------------------------------------------- /src/protections/RoomTakedown/RoomDiscovery.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | import { PolicyRuleChange, RoomBasicDetails } from "matrix-protection-suite"; 6 | import { RoomToCheck } from "./DiscoveredRoomStore"; 7 | import { Result } from "@gnuxie/typescript-result"; 8 | import { StringRoomID } from "@the-draupnir-project/matrix-basic-types"; 9 | 10 | // FIXME: This should really have its own "Room acknowledged" table in the audit log... 11 | // which only sends when a notification has been sent to the admin room. 12 | // This would allow for complete coverage... 13 | 14 | export type RoomDiscoveryListener = (details: RoomBasicDetails) => void; 15 | 16 | export interface RoomDiscovery { 17 | on(event: "RoomDiscovery", listener: RoomDiscoveryListener): this; 18 | off(event: "RoomDiscovery", listener: RoomDiscoveryListener): this; 19 | emit(event: "RoomDiscovery", details: RoomBasicDetails): void; 20 | checkRoomsDiscovered( 21 | roomsToCheck: RoomToCheck[] 22 | ): Promise>; 23 | /** FIXME: I do not like that this is exposed */ 24 | isRoomDiscovered(roomID: StringRoomID): boolean; 25 | } 26 | 27 | export interface RoomExplorer { 28 | handlePolicyChange(change: PolicyRuleChange[]): void; 29 | unregisterListeners(): void; 30 | } 31 | -------------------------------------------------------------------------------- /src/safemode/commands/HelpCommand.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { SafeModeCommands } from "./SafeModeCommands"; 6 | import { SafeModeInterfaceAdaptor } from "./SafeModeAdaptor"; 7 | 8 | import { Result } from "@gnuxie/typescript-result"; 9 | import { 10 | DeadDocumentJSX, 11 | describeCommand, 12 | TopPresentationSchema, 13 | CommandTable, 14 | } from "@the-draupnir-project/interface-manager"; 15 | import { Ok } from "matrix-protection-suite"; 16 | import { renderTableHelp } from "@the-draupnir-project/mps-interface-adaptor"; 17 | import { safeModeHeader } from "./StatusCommand"; 18 | import { DOCUMENTATION_URL } from "../../config"; 19 | 20 | export const SafeModeHelpCommand = describeCommand({ 21 | rest: { 22 | name: "command parts", 23 | acceptor: TopPresentationSchema, 24 | }, 25 | summary: "Display this message", 26 | executor: async function ( 27 | _context, 28 | _keywords 29 | ): Promise> { 30 | return Ok(SafeModeCommands); 31 | }, 32 | parameters: [], 33 | }); 34 | 35 | SafeModeInterfaceAdaptor.describeRenderer(SafeModeHelpCommand, { 36 | JSXRenderer() { 37 | return Ok( 38 | 39 | {safeModeHeader()} 40 | {renderTableHelp(SafeModeCommands, DOCUMENTATION_URL)} 41 | 42 | ); 43 | }, 44 | }); 45 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Aminda Suomalainen 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | # See https://pre-commit.com for more information 6 | # See https://pre-commit.com/hooks.html for more hooks 7 | # See https://pre-commit.ci for more information 8 | ci: 9 | autoupdate_schedule: weekly 10 | skip: [yarn-lint] 11 | repos: 12 | - repo: https://github.com/pre-commit/pre-commit-hooks 13 | rev: v5.0.0 14 | hooks: 15 | - id: trailing-whitespace 16 | args: ["--markdown-linebreak-ext", "md"] 17 | exclude_types: [svg] 18 | - id: end-of-file-fixer 19 | - id: check-yaml 20 | - id: check-added-large-files 21 | - id: check-executables-have-shebangs 22 | - id: check-shebang-scripts-are-executable 23 | - id: check-illegal-windows-names 24 | - repo: https://github.com/editorconfig-checker/editorconfig-checker.python 25 | rev: "3.0.3" 26 | hooks: 27 | - id: editorconfig-checker 28 | alias: ec 29 | - repo: https://github.com/fsfe/reuse-tool 30 | rev: v4.0.3 31 | hooks: 32 | - id: reuse 33 | - repo: local 34 | hooks: 35 | - id: yarn-lint 36 | name: linter 37 | entry: corepack yarn lint 38 | language: system 39 | - repo: https://github.com/python-jsonschema/check-jsonschema 40 | rev: 0.29.3 41 | hooks: 42 | - id: check-renovate 43 | additional_dependencies: ["pyjson5"] 44 | -------------------------------------------------------------------------------- /test/unit/config/unknownPropertiesTest.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import expect from "expect"; 6 | import { 7 | getNonDefaultConfigProperties, 8 | getUnknownConfigPropertyPaths, 9 | } from "../../../src/config"; 10 | import { IConfig } from "../../../src/config"; 11 | 12 | describe("Test unknown properties detection", () => { 13 | it("Should detect when there are typos in the config", function () { 14 | const config = { 15 | pantalaimon: { 16 | use: true, 17 | passweird: "my password hehe", 18 | }, 19 | }; 20 | const unknownProperties = getUnknownConfigPropertyPaths(config); 21 | expect(unknownProperties.length).toBe(1); 22 | expect(unknownProperties[0]).toBe("/pantalaimon/passweird"); 23 | }); 24 | }); 25 | 26 | describe("Test non-default values detection", () => { 27 | it("Should detect when there are non-default values in the config", function () { 28 | const config = { 29 | pantalaimon: { 30 | use: true, 31 | password: "my password hehe", 32 | }, 33 | }; 34 | const differentProperties = getNonDefaultConfigProperties( 35 | config as IConfig 36 | ) as unknown as IConfig; 37 | expect(Object.entries(differentProperties).length).toBe(1); 38 | expect(differentProperties.pantalaimon.password).toBe("REDACTED"); 39 | expect(differentProperties.pantalaimon.use).toBe(true); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | SPDX-PackageName = "Draupnir" 3 | SPDX-PackageSupplier = "Gnuxie " 4 | SPDX-PackageDownloadLocation = "https://github.com/the-draupnir-project/Draupnir" 5 | 6 | [[annotations]] 7 | path = ".github/ISSUE_TEMPLATE/bug-report.md" 8 | precedence = "aggregate" 9 | SPDX-FileCopyrightText = "Gnuxie " 10 | SPDX-License-Identifier = "CC0-1.0" 11 | 12 | [[annotations]] 13 | path = "tsconfig.json" 14 | precedence = "aggregate" 15 | SPDX-FileCopyrightText = "Gnuxie " 16 | SPDX-License-Identifier = "CC0-1.0" 17 | 18 | [[annotations]] 19 | path = "REUSE.toml" 20 | precedence = "aggregate" 21 | SPDX-FileCopyrightText = "Gnuxie " 22 | SPDX-License-Identifier = "CC0-1.0" 23 | 24 | [[annotations]] 25 | path = "yarn.lock" 26 | precedence = "aggregate" 27 | SPDX-FileCopyrightText = "Gnuxie " 28 | SPDX-License-Identifier = "CC0-1.0" 29 | 30 | [[annotations]] 31 | path = "NOTICE" 32 | precedence = "aggregate" 33 | SPDX-FileCopyrightText = "Gnuxie " 34 | SPDX-License-Identifier = "CC0-1.0" 35 | 36 | [[annotations]] 37 | path = "test/tsconfig.json" 38 | precedence = "aggregate" 39 | SPDX-FileCopyrightText = "Gnuxie " 40 | SPDX-License-Identifier = "CC0-1.0" 41 | 42 | [[annotations]] 43 | path = "package.json" 44 | precedence = "aggregate" 45 | SPDX-FileCopyrightText = "Gnuxie " 46 | SPDX-License-Identifier = "AFL-3.0" 47 | -------------------------------------------------------------------------------- /src/commands/SetDisplayNameCommand.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Marcel 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { Ok, Result } from "@gnuxie/typescript-result"; 6 | import { 7 | StringPresentationType, 8 | describeCommand, 9 | } from "@the-draupnir-project/interface-manager"; 10 | import { Draupnir } from "../Draupnir"; 11 | import { ActionError } from "matrix-protection-suite"; 12 | import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites"; 13 | 14 | export const DraupnirDisplaynameCommand = describeCommand({ 15 | summary: 16 | "Sets the displayname of the draupnir instance to the specified value in all rooms.", 17 | parameters: [], 18 | rest: { 19 | name: "displayname", 20 | acceptor: StringPresentationType, 21 | }, 22 | async executor( 23 | draupnir: Draupnir, 24 | _info, 25 | _keywords, 26 | displaynameParts 27 | ): Promise> { 28 | const displayname = displaynameParts.join(" "); 29 | try { 30 | await draupnir.client.setDisplayName(displayname); 31 | } catch (e) { 32 | const message = e.message || (e.body ? e.body.error : ""); 33 | return ActionError.Result( 34 | `Failed to set displayname to ${displayname}: ${message}` 35 | ); 36 | } 37 | return Ok(undefined); 38 | }, 39 | }); 40 | 41 | DraupnirInterfaceAdaptor.describeRenderer(DraupnirDisplaynameCommand, { 42 | isAlwaysSupposedToUseDefaultRenderer: true, 43 | }); 44 | -------------------------------------------------------------------------------- /src/capabilities/RoomTakedownCapability.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | import { Result } from "@gnuxie/typescript-result"; 6 | import { Type } from "@sinclair/typebox"; 7 | import { StringRoomID } from "@the-draupnir-project/matrix-basic-types"; 8 | import { 9 | Capability, 10 | CapabilityMethodSchema, 11 | describeCapabilityInterface, 12 | RoomBasicDetails, 13 | } from "matrix-protection-suite"; 14 | 15 | export interface RoomDetailsProvider { 16 | getRoomDetails(roomID: StringRoomID): Promise>; 17 | } 18 | 19 | export const RoomTakedownCapability = Type.Intersect([ 20 | Type.Object({ 21 | isRoomTakendown: CapabilityMethodSchema, 22 | takedownRoom: CapabilityMethodSchema, 23 | }), 24 | Capability, 25 | ]); 26 | // we'll probably want to include room details too so we can provide some 27 | // rudinemtary information to takedown. 28 | // I'd make the fields optional however because it may be impossible 29 | // to get those details on conduwuit. 30 | export type RoomTakedownCapability = { 31 | isRoomTakendown(roomID: StringRoomID): Promise>; 32 | takedownRoom(roomID: StringRoomID): Promise>; 33 | } & RoomDetailsProvider & 34 | Capability; 35 | 36 | describeCapabilityInterface({ 37 | name: "RoomTakedownCapability", 38 | description: "Capability that targets matrix rooms", 39 | schema: RoomTakedownCapability, 40 | }); 41 | -------------------------------------------------------------------------------- /src/protections/HomeserverUserPolicyApplication/UserRestrictionCapability.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { Result } from "@gnuxie/typescript-result"; 6 | import { Type } from "@sinclair/typebox"; 7 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; 8 | import { 9 | Capability, 10 | CapabilityMethodSchema, 11 | describeCapabilityInterface, 12 | LiteralPolicyRule, 13 | } from "matrix-protection-suite"; 14 | import { AccountRestriction } from "matrix-protection-suite-for-matrix-bot-sdk"; 15 | 16 | export interface UserRestrictionCapability extends Capability { 17 | isUserRestricted(userID: StringUserID): Promise>; 18 | restrictUser( 19 | userID: StringUserID, 20 | options: { 21 | rule: LiteralPolicyRule | null; 22 | sender: StringUserID; 23 | } 24 | ): Promise>; 25 | unrestrictUser( 26 | userID: StringUserID, 27 | sender: StringUserID 28 | ): Promise>; 29 | } 30 | 31 | const UserRestrictionCapability = Type.Object({ 32 | unrestrictUser: CapabilityMethodSchema, 33 | restrictUser: CapabilityMethodSchema, 34 | isUserRestricted: CapabilityMethodSchema, 35 | }); 36 | 37 | describeCapabilityInterface({ 38 | name: "UserRestrictionCapability", 39 | description: 40 | "Capabilities to restrict and unrestrict users from the homeserver", 41 | schema: UserRestrictionCapability, 42 | }); 43 | -------------------------------------------------------------------------------- /src/safemode/commands/SafeModeCommands.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { 6 | StandardCommandTable, 7 | StringfromBooleanTranslator, 8 | StringFromMatrixEventReferenceTranslator, 9 | StringFromMatrixRoomAliasTranslator, 10 | StringFromMatrixRoomIDTranslator, 11 | StringFromMatrixUserIDTranslator, 12 | StringFromNumberTranslator, 13 | } from "@the-draupnir-project/interface-manager"; 14 | import { SafeModeHelpCommand } from "./HelpCommand"; 15 | import { SafeModeStatusCommand } from "./StatusCommand"; 16 | import { SafeModeRestartCommand } from "./RestartDraupnirCommand"; 17 | import { SafeModeRecoverCommand } from "./RecoverCommand"; 18 | 19 | export const SafeModeCommands = new StandardCommandTable("safe mode") 20 | .internPresentationTypeTranslator(StringFromNumberTranslator) 21 | .internPresentationTypeTranslator(StringfromBooleanTranslator) 22 | .internPresentationTypeTranslator(StringFromMatrixRoomIDTranslator) 23 | .internPresentationTypeTranslator(StringFromMatrixRoomAliasTranslator) 24 | .internPresentationTypeTranslator(StringFromMatrixUserIDTranslator) 25 | .internPresentationTypeTranslator(StringFromMatrixEventReferenceTranslator) 26 | .internCommand(SafeModeHelpCommand, ["draupnir", "help"]) 27 | .internCommand(SafeModeStatusCommand, ["draupnir", "status"]) 28 | .internCommand(SafeModeRecoverCommand, ["draupnir", "recover"]) 29 | .internCommand(SafeModeRestartCommand, ["draupnir", "restart"]); 30 | -------------------------------------------------------------------------------- /test/appservice/utils/ProvisionHelper.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { Ok, Result, ResultError, isError } from "@gnuxie/typescript-result"; 6 | import { Draupnir } from "../../../src/Draupnir"; 7 | import { MjolnirAppService } from "../../../src/appservice/AppService"; 8 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; 9 | 10 | export interface ProvisionHelper { 11 | /** 12 | * Automatically make a draupnir and a management room. 13 | */ 14 | provisionDraupnir(requestingUserID: StringUserID): Promise>; 15 | } 16 | 17 | export class StandardProvisionHelper implements ProvisionHelper { 18 | public constructor(private readonly appservice: MjolnirAppService) { 19 | // nothing to do. 20 | } 21 | async provisionDraupnir( 22 | requestingUserID: StringUserID 23 | ): Promise> { 24 | const provisionResult = 25 | await this.appservice.draupnirManager.provisionNewDraupnir( 26 | requestingUserID 27 | ); 28 | if (isError(provisionResult)) { 29 | return provisionResult; 30 | } 31 | const draupnir = await this.appservice.draupnirManager.getRunningDraupnir( 32 | this.appservice.draupnirManager.draupnirMXID(provisionResult.ok), 33 | requestingUserID 34 | ); 35 | if (draupnir === undefined) { 36 | return ResultError.Result(`Failed to find draupnir after provisioning`); 37 | } 38 | return Ok(draupnir); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/appservice/datastore.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // Copyright 2022 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { 12 | StringUserID, 13 | StringRoomID, 14 | } from "@the-draupnir-project/matrix-basic-types"; 15 | 16 | export interface MjolnirRecord { 17 | local_part: string; 18 | owner: StringUserID; 19 | management_room: StringRoomID; 20 | } 21 | 22 | /** 23 | * Used to persist draupnirs that have been provisioned by the draupnir manager. 24 | */ 25 | export interface DataStore { 26 | /** 27 | * Initialize any resources that the datastore needs to function. 28 | */ 29 | init(): Promise; 30 | 31 | /** 32 | * Close any resources that the datastore is using. 33 | */ 34 | close(): Promise; 35 | 36 | /** 37 | * List all of the draupnirs we have provisioned. 38 | */ 39 | list(): Promise; 40 | 41 | /** 42 | * Persist a new `MjolnirRecord`. 43 | */ 44 | store(mjolnirRecord: MjolnirRecord): Promise; 45 | 46 | /** 47 | * @param owner The mxid of the user who provisioned this draupnir. 48 | */ 49 | lookupByOwner(owner: string): Promise; 50 | 51 | /** 52 | * @param localPart the mxid of the provisioned draupnir. 53 | */ 54 | lookupByLocalPart(localPart: string): Promise; 55 | } 56 | -------------------------------------------------------------------------------- /src/safemode/SafeModeToggle.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { Err, Result, ResultError } from "@gnuxie/typescript-result"; 6 | import { Draupnir } from "../Draupnir"; 7 | import { SafeModeDraupnir } from "./DraupnirSafeMode"; 8 | import { SafeModeCause } from "./SafeModeCause"; 9 | 10 | export type SafeModeToggleOptions = { sendStatusOnStart?: boolean }; 11 | 12 | export class DraupnirRestartError extends ResultError { 13 | constructor( 14 | message: string, 15 | public readonly safeModeDraupnir: SafeModeDraupnir 16 | ) { 17 | super(message); 18 | } 19 | 20 | public static Result( 21 | message: string, 22 | options: { safeModeDraupnir: SafeModeDraupnir } 23 | ): Result { 24 | return Err(new DraupnirRestartError(message, options.safeModeDraupnir)); 25 | } 26 | } 27 | 28 | export interface SafeModeToggle { 29 | /** 30 | * Switch the bot to Draupnir mode. 31 | * We expect that the result represents the entire conversion. 32 | * We expect that the same matrix client is shared between the bots. 33 | * That means that by the command responds with ticks and crosses, 34 | * draupnir will be running or we will still be in safe mode. 35 | */ 36 | switchToDraupnir( 37 | options?: SafeModeToggleOptions 38 | ): Promise>; 39 | switchToSafeMode( 40 | cause: SafeModeCause, 41 | options?: SafeModeToggleOptions 42 | ): Promise>; 43 | } 44 | -------------------------------------------------------------------------------- /test/appservice/integration/listUnstartedDraupnirTest.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import expect from "expect"; 6 | import { MjolnirAppService } from "../../../src/appservice/AppService"; 7 | import { setupHarness } from "../utils/harness"; 8 | import { isError } from "matrix-protection-suite"; 9 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; 10 | 11 | interface Context extends Mocha.Context { 12 | appservice?: MjolnirAppService; 13 | } 14 | 15 | describe("Just test some commands innit", function () { 16 | beforeEach(async function (this: Context) { 17 | this.appservice = await setupHarness(); 18 | }); 19 | afterEach(function (this: Context) { 20 | if (this.appservice) { 21 | return this.appservice.close(); 22 | } else { 23 | console.warn("Missing Appservice in this context, so cannot stop it."); 24 | return Promise.resolve(); // TS7030: Not all code paths return a value. 25 | } 26 | }); 27 | it("Can list any unstarted draupnir", async function (this: Context) { 28 | const appservice = this.appservice; 29 | if (appservice === undefined) { 30 | throw new TypeError(`Test setup failed`); 31 | } 32 | const result = await appservice.commands.sendTextCommand( 33 | "@test:localhost:9999" as StringUserID, 34 | "!admin list unstarted" 35 | ); 36 | if (isError(result)) { 37 | throw new TypeError(`Command should have succeeded`); 38 | } 39 | expect(result.ok).toBeInstanceOf(Array); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/appservice/bot/AppserviceBotHelp.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2023 - 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { Result } from "@gnuxie/typescript-result"; 6 | import { 7 | CommandTable, 8 | DeadDocumentJSX, 9 | DocumentNode, 10 | ParsedKeywords, 11 | TopPresentationSchema, 12 | describeCommand, 13 | } from "@the-draupnir-project/interface-manager"; 14 | import { ActionResult, Ok } from "matrix-protection-suite"; 15 | import { AppserviceBotCommands } from "./AppserviceBotCommandTable"; 16 | import { AppserviceBotInterfaceAdaptor } from "./AppserviceBotInterfaceAdaptor"; 17 | import { 18 | MatrixAdaptorContext, 19 | renderTableHelp, 20 | } from "@the-draupnir-project/mps-interface-adaptor"; 21 | import { DOCUMENTATION_URL } from "../../config"; 22 | 23 | export const AppserviceBotHelpCommand = describeCommand({ 24 | rest: { 25 | name: "command parts", 26 | acceptor: TopPresentationSchema, 27 | }, 28 | summary: "Display this message", 29 | executor: async function ( 30 | _context: MatrixAdaptorContext, 31 | _keywords: ParsedKeywords, 32 | ..._args: unknown[] 33 | ): Promise> { 34 | return Ok(AppserviceBotCommands); 35 | }, 36 | parameters: [], 37 | }); 38 | 39 | function renderAppserviceBotHelp(): Result { 40 | return Ok( 41 | {renderTableHelp(AppserviceBotCommands, DOCUMENTATION_URL)} 42 | ); 43 | } 44 | 45 | AppserviceBotInterfaceAdaptor.describeRenderer(AppserviceBotHelpCommand, { 46 | JSXRenderer: renderAppserviceBotHelp, 47 | }); 48 | -------------------------------------------------------------------------------- /src/safemode/RecoveryOptions.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { 6 | DeadDocumentJSX, 7 | DocumentNode, 8 | } from "@the-draupnir-project/interface-manager"; 9 | import { ConfigRecoverableError } from "matrix-protection-suite"; 10 | import { SafeModeCause, SafeModeReason } from "./SafeModeCause"; 11 | import { MatrixReactionHandler } from "@the-draupnir-project/mps-interface-adaptor"; 12 | 13 | export function renderRecoveryOptions(cause: SafeModeCause): DocumentNode { 14 | const recoveryOptions = 15 | cause.reason === SafeModeReason.ByRequest 16 | ? [] 17 | : cause.error instanceof ConfigRecoverableError 18 | ? cause.error.recoveryOptions 19 | : []; 20 | if (recoveryOptions.length === 0) { 21 | return ( 22 | 23 | No recovery options are available for this failure mode. 24 | 25 | ); 26 | } 27 | return ( 28 | 29 |

30 | Recovery options are available for this failure mode: 31 |

    32 | {recoveryOptions.map((option) => ( 33 |
  1. {option.description}
  2. 34 | ))} 35 |
36 |

37 |
38 |

39 | To use a recovery option, click on one of the reactions ( 40 | {recoveryOptions 41 | .map((_, index) => MatrixReactionHandler.numberToEmoji(index + 1)) 42 | .join(", ")} 43 | ), or use the recover command: !draupnir recover 1. 44 |

45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/health/healthz.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // Copyright 2020 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import * as http from "http"; 12 | import { LogService } from "matrix-bot-sdk"; 13 | import { IConfig } from "../config"; 14 | // allowed to use the global configuration since this is only intended to be used by `src/index.ts`. 15 | 16 | export class Healthz { 17 | private healthCode: number; 18 | 19 | constructor(private config: IConfig) { 20 | this.healthCode = this.config.health.healthz.unhealthyStatus; 21 | } 22 | 23 | public set isHealthy(val: boolean) { 24 | this.healthCode = val 25 | ? this.config.health.healthz.healthyStatus 26 | : this.config.health.healthz.unhealthyStatus; 27 | } 28 | 29 | public get isHealthy(): boolean { 30 | return this.healthCode === this.config.health.healthz.healthyStatus; 31 | } 32 | 33 | public listen() { 34 | const server = http.createServer((req, res) => { 35 | res.writeHead(this.healthCode); 36 | res.end(`health code: ${this.healthCode}`); 37 | }); 38 | server.listen( 39 | this.config.health.healthz.port, 40 | this.config.health.healthz.address, 41 | () => { 42 | LogService.info( 43 | "Healthz", 44 | `Listening for health requests on ${this.config.health.healthz.address}:${this.config.health.healthz.port}` 45 | ); 46 | } 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/appservice/cli.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // Copyright 2022 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { Cli } from "matrix-appservice-bridge"; 12 | import { MjolnirAppService } from "./AppService"; 13 | import { AppserviceConfig } from "./config/config"; 14 | import { Value } from "@sinclair/typebox/value"; 15 | 16 | /** 17 | * This file provides the entrypoint for the appservice mode for draupnir. 18 | * A registration file can be generated `ts-node src/appservice/cli.ts -r -u "http://host.docker.internal:9000"` 19 | * and the appservice can be started with `ts-node src/appservice/cli -p 9000 -c your-confg.yaml`. 20 | */ 21 | const cli = new Cli({ 22 | registrationPath: "draupnir-registration.yaml", 23 | bridgeConfig: { 24 | schema: {}, 25 | affectsRegistration: false, 26 | defaults: {}, 27 | }, 28 | generateRegistration: MjolnirAppService.generateRegistration, 29 | run: function (port: number) { 30 | const config = cli.getConfig(); 31 | if (config === null) { 32 | throw new Error("Couldn't load config"); 33 | } 34 | void MjolnirAppService.run( 35 | port, 36 | // we use the matrix-appservice-bridge library to handle cli arguments for loading the config 37 | // but we have to still validate it ourselves. 38 | Value.Decode(AppserviceConfig, config), 39 | cli.getRegistrationFilePath() 40 | ); 41 | }, 42 | }); 43 | 44 | cli.run(); 45 | -------------------------------------------------------------------------------- /src/commands/Help.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 - 2024 Gnuxie 2 | // Copyright 2019 - 2022 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { Ok } from "matrix-protection-suite"; 12 | import { 13 | CommandTable, 14 | DocumentNode, 15 | DeadDocumentJSX, 16 | describeCommand, 17 | describeRestParameters, 18 | } from "@the-draupnir-project/interface-manager"; 19 | import { DraupnirTopLevelCommands } from "./DraupnirCommandTable"; 20 | import { TopPresentationSchema } from "@the-draupnir-project/interface-manager/dist/Command/PresentationSchema"; 21 | import { renderTableHelp } from "@the-draupnir-project/mps-interface-adaptor"; 22 | import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites"; 23 | import { DOCUMENTATION_URL } from "../config"; 24 | 25 | function renderDraupnirHelp(mjolnirTable: CommandTable): DocumentNode { 26 | return {renderTableHelp(mjolnirTable, DOCUMENTATION_URL)}; 27 | } 28 | 29 | export const DraupnirHelpCommand = describeCommand({ 30 | async executor() { 31 | return Ok(DraupnirTopLevelCommands); 32 | }, 33 | parameters: [], 34 | rest: describeRestParameters({ 35 | name: "command parts", 36 | acceptor: TopPresentationSchema, 37 | }), 38 | summary: "Display this message", 39 | }); 40 | 41 | DraupnirInterfaceAdaptor.describeRenderer(DraupnirHelpCommand, { 42 | JSXRenderer() { 43 | return Ok(renderDraupnirHelp(DraupnirTopLevelCommands)); 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Draupnir Bot Mode Bug Template 3 | about: A template for a bug encountered in Bot Mode 4 | title: "Draupnir will not X while using the Z command" 5 | labels: ["bug"] 6 | --- 7 | 8 | _It is not required to fill out each of these fields, your bug will still be 9 | considered, even if you do not follow the template. However, please do try 10 | provide as much detail as possible to save us time._ 11 | 12 | **How is Draupnir setup?** 13 | 14 | _Try to include any details about non default configuration or any context to 15 | its use that might be relevant to the issue. If Draupnir has crashed, then these 16 | will be printed at the end of the log, you should copy that output here._ 17 | 18 | I was using Draupnir with the `roomStateBacking` store enabled. All other 19 | configuration options were default. 20 | 21 | **What were you trying to achieve?** 22 | 23 | _Include details about what you were trying to accomplish without workarounds._ 24 | 25 | I was trying to ban a server from my rooms by using the ban command. 26 | 27 | **What steps did you take to encounter the bug** 28 | 29 | _Try to describe clear concise steps or any details about how you encountered 30 | the bug._ 31 | 32 | 1. I ran `!draupnir ban server.example.com coc spam` in the management room. 33 | 2. I checked my protected rooms 34 | 3. I could not see the server ACL event updating. 35 | 36 | **What is the bug or what did you expect to happen instead?** 37 | 38 | _Try describe the outcome you would expect, or explicitly state what the bug 39 | is_. 40 | 41 | I expected Draupnir to have changed the `m.room.server_acl` event and ban the 42 | server from all my rooms. 43 | -------------------------------------------------------------------------------- /src/protections/ConfigHooks.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { 6 | ActionResult, 7 | Ok, 8 | ProtectionsConfig, 9 | ServerBanSynchronisationProtection, 10 | isError, 11 | } from "matrix-protection-suite"; 12 | import { IConfig } from "../config"; 13 | import { RoomTakedownProtection } from "./RoomTakedown/RoomTakedownProtection"; 14 | import { BlockInvitationsOnServerProtection } from "./BlockInvitationsOnServerProtection"; 15 | 16 | type ConfigHook = ( 17 | config: IConfig, 18 | protectionsConfig: ProtectionsConfig 19 | ) => Promise>; 20 | 21 | export const ServerAdminProtections = [ 22 | RoomTakedownProtection, 23 | BlockInvitationsOnServerProtection, 24 | ]; 25 | 26 | const hooks: ConfigHook[] = [ 27 | async function disableServerACL(config, protectionsConfig) { 28 | if (config.disableServerACL) { 29 | return await protectionsConfig.disableProtection( 30 | ServerBanSynchronisationProtection.name 31 | ); 32 | } else { 33 | return Ok(undefined); 34 | } 35 | }, 36 | ]; 37 | 38 | /** 39 | * Introduced to allow the legacy option `config.disableServerACL` to map onto 40 | * MPS's ServerBanSynchronisationProtection. I think we need to deprecate the 41 | * option and offer something else. 42 | */ 43 | export async function runProtectionConfigHooks( 44 | config: IConfig, 45 | protectionsConfig: ProtectionsConfig 46 | ): Promise> { 47 | for (const hook of hooks) { 48 | const result = await hook(config, protectionsConfig); 49 | if (isError(result)) { 50 | return result; 51 | } 52 | } 53 | return Ok(undefined); 54 | } 55 | -------------------------------------------------------------------------------- /src/protections/ProtectedRoomsSetRenderers.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 - 2024 Gnuxie 2 | // Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { 12 | ActionError, 13 | ProtectionDescription, 14 | RoomMessageSender, 15 | Task, 16 | } from "matrix-protection-suite"; 17 | import { StringRoomID } from "@the-draupnir-project/matrix-basic-types"; 18 | import { sendMatrixEventsFromDeadDocument } from "@the-draupnir-project/mps-interface-adaptor"; 19 | import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager"; 20 | import { Result } from "@gnuxie/typescript-result"; 21 | 22 | // The callback that this is required for in MPS, is kinda silly and should 23 | // really be `void` and not `Promise`. If it wanted to be `Promise`, 24 | // then it should really be `Promise>`. 25 | export async function renderProtectionFailedToStart( 26 | roomMessageSender: RoomMessageSender, 27 | managementRoomID: StringRoomID, 28 | error: ActionError, 29 | protectionName: string, 30 | _protectionDescription?: ProtectionDescription 31 | ): Promise { 32 | void Task( 33 | sendMatrixEventsFromDeadDocument( 34 | roomMessageSender, 35 | managementRoomID, 36 | 37 | 38 | A protection {protectionName} failed to start for the following 39 | reason: 40 | 41 | {error.message} 42 | , 43 | {} 44 | ) as Promise> 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/managementroom/ManagementRoomDetail.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { 6 | MatrixRoomID, 7 | StringRoomID, 8 | StringUserID, 9 | } from "@the-draupnir-project/matrix-basic-types"; 10 | import { 11 | JoinRulesEvent, 12 | Membership, 13 | RoomMembershipRevisionIssuer, 14 | RoomStateRevisionIssuer, 15 | } from "matrix-protection-suite"; 16 | 17 | export interface ManagementRoomDetail { 18 | isRoomPublic(): boolean; 19 | isModerator(userID: StringUserID): boolean; 20 | managementRoom: MatrixRoomID; 21 | managementRoomID: StringRoomID; 22 | } 23 | 24 | export class StandardManagementRoomDetail implements ManagementRoomDetail { 25 | public constructor( 26 | public readonly managementRoom: MatrixRoomID, 27 | private readonly membershipIssuer: RoomMembershipRevisionIssuer, 28 | private readonly stateIssuer: RoomStateRevisionIssuer 29 | ) { 30 | // nothing to do mare. 31 | } 32 | 33 | public isRoomPublic(): boolean { 34 | const joinRuleEvent = 35 | this.stateIssuer.currentRevision.getStateEvent( 36 | "m.room.join_rules", 37 | "" 38 | ); 39 | if (joinRuleEvent === undefined) { 40 | return false; // auth rules are fail safe. 41 | } 42 | return joinRuleEvent.content.join_rule === "public"; 43 | } 44 | 45 | public isModerator(userID: StringUserID): boolean { 46 | return ( 47 | this.membershipIssuer.currentRevision.membershipForUser(userID) 48 | ?.membership === Membership.Join 49 | ); 50 | } 51 | 52 | public get managementRoomID(): StringRoomID { 53 | return this.managementRoom.toRoomIDOrAlias(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/commands/ResolveAlias.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // Copyright 2020 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { renderRoomPill } from "@the-draupnir-project/mps-interface-adaptor"; 12 | import { MatrixRoomID } from "@the-draupnir-project/matrix-basic-types"; 13 | import { 14 | DeadDocumentJSX, 15 | MatrixRoomAliasPresentationType, 16 | describeCommand, 17 | tuple, 18 | } from "@the-draupnir-project/interface-manager"; 19 | import { Draupnir } from "../Draupnir"; 20 | import { Ok, Result, isError } from "@gnuxie/typescript-result"; 21 | import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites"; 22 | 23 | export const DraupnirResolveAliasCommand = describeCommand({ 24 | summary: "Resolve a room alias.", 25 | parameters: tuple({ 26 | name: "alias", 27 | acceptor: MatrixRoomAliasPresentationType, 28 | }), 29 | async executor( 30 | { clientPlatform }: Draupnir, 31 | _info, 32 | _keywords, 33 | _rest, 34 | alias 35 | ): Promise> { 36 | return await clientPlatform.toRoomResolver().resolveRoom(alias); 37 | }, 38 | }); 39 | 40 | DraupnirInterfaceAdaptor.describeRenderer(DraupnirResolveAliasCommand, { 41 | JSXRenderer(result) { 42 | if (isError(result)) { 43 | return Ok(undefined); 44 | } else { 45 | return Ok( 46 | 47 | {result.ok.toRoomIDOrAlias()} -{" "} 48 | {renderRoomPill(result.ok)} 49 | 50 | ); 51 | } 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /src/protections/HomeserverUserPolicyApplication/deactivateUser.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { isError, Ok, Result } from "@gnuxie/typescript-result"; 6 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; 7 | import { LiteralPolicyRule, Logger } from "matrix-protection-suite"; 8 | import { 9 | AccountRestriction, 10 | SynapseAdminClient, 11 | } from "matrix-protection-suite-for-matrix-bot-sdk"; 12 | import { UserRestrictionAuditLog } from "./UserRestrictionAuditLog"; 13 | 14 | const log = new Logger("deactivateUser"); 15 | 16 | export async function deactivateUser( 17 | userID: StringUserID, 18 | synapseAdminClient: SynapseAdminClient, 19 | userAuditLog: UserRestrictionAuditLog, 20 | options: { 21 | rule: LiteralPolicyRule | null; 22 | sender: StringUserID; 23 | } 24 | ): Promise> { 25 | const userDetails = await synapseAdminClient.getUserDetails(userID); 26 | if (isError(userDetails)) { 27 | log.error("Unable to query user details", userID); 28 | return userDetails; 29 | } 30 | if (userDetails.ok?.deactivated) { 31 | log.debug("User is already deactivated"); 32 | return Ok(undefined); 33 | } 34 | const deactivationResult = await synapseAdminClient.deactivateUser(userID, { 35 | erase: true, 36 | }); 37 | if (isError(deactivationResult)) { 38 | return deactivationResult; 39 | } 40 | const auditResult = await userAuditLog.recordUserRestriction( 41 | userID, 42 | AccountRestriction.Deactivated, 43 | options 44 | ); 45 | if (isError(auditResult)) { 46 | log.error("Unable to audit deactivation", userID); 47 | } 48 | return deactivationResult; 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/docker-hub-develop.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Gnuxie 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | # Copied from https://github.com/matrix-org/matrix-bifrost/blob/develop/.github/workflows/docker-hub-latest.yml 6 | 7 | name: "Docker Hub - Develop" 8 | 9 | on: 10 | push: 11 | branches: 12 | - main 13 | 14 | env: 15 | DOCKER_NAMESPACE: gnuxie 16 | PLATFORMS: linux/amd64,linux/arm64 17 | # Only push if this is main, otherwise we just want to build 18 | PUSH: ${{ github.ref == 'refs/heads/main' }} 19 | 20 | jobs: 21 | docker-latest: 22 | runs-on: ubuntu-latest 23 | permissions: 24 | id-token: write 25 | packages: write 26 | contents: read 27 | attestations: write 28 | steps: 29 | - name: Check out 30 | uses: actions/checkout@v4 31 | - name: Unshallow for git describe so we can create version.txt 32 | run: git fetch --prune --unshallow --tags --all --force 33 | 34 | # Needed for multi platform builds 35 | - name: Set up QEMU 36 | uses: docker/setup-qemu-action@v3 37 | with: 38 | platforms: ${{ env.PLATFORMS }} 39 | 40 | - name: Set up Docker Buildx 41 | uses: docker/setup-buildx-action@v3 42 | 43 | - name: Log in to Docker Hub 44 | uses: docker/login-action@v3 45 | with: 46 | username: ${{ secrets.DOCKERHUB_USERNAME }} 47 | password: ${{ secrets.DOCKERHUB_TOKEN }} 48 | 49 | - name: Build image 50 | id: push 51 | uses: docker/build-push-action@v5 52 | with: 53 | context: . 54 | file: ./Dockerfile 55 | platforms: ${{ env.PLATFORMS }} 56 | push: ${{ env.PUSH }} 57 | tags: | 58 | ${{ env.DOCKER_NAMESPACE }}/draupnir:develop 59 | -------------------------------------------------------------------------------- /src/commands/server-admin/UnrestrictCommand.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { 6 | BasicInvocationInformation, 7 | describeCommand, 8 | MatrixUserIDPresentationType, 9 | tuple, 10 | } from "@the-draupnir-project/interface-manager"; 11 | import { Draupnir } from "../../Draupnir"; 12 | import { Result, ResultError } from "@gnuxie/typescript-result"; 13 | import { SynapseAdminUserSuspensionCapability } from "../../protections/HomeserverUserPolicyApplication/UserSuspensionCapability"; 14 | import { DraupnirInterfaceAdaptor } from "../DraupnirCommandPrerequisites"; 15 | 16 | export const SynpaseAdminUnrestrictUserCommand = describeCommand({ 17 | summary: 18 | "Unrestrict a user on the homeserver if their account has been suspended or shadowbanned", 19 | parameters: tuple({ 20 | name: "user", 21 | description: "The user to unrestrict", 22 | acceptor: MatrixUserIDPresentationType, 23 | }), 24 | async executor( 25 | draupnir: Draupnir, 26 | info: BasicInvocationInformation, 27 | _keywords, 28 | _rest, 29 | targetUser 30 | ): Promise> { 31 | if ( 32 | draupnir.synapseAdminClient === undefined || 33 | draupnir.stores.userRestrictionAuditLog === undefined 34 | ) { 35 | return ResultError.Result( 36 | "This command cannot be used without synapse admin" 37 | ); 38 | } 39 | const capability = new SynapseAdminUserSuspensionCapability( 40 | draupnir.synapseAdminClient, 41 | draupnir.stores.userRestrictionAuditLog 42 | ); 43 | return await capability.unrestrictUser( 44 | targetUser.toString(), 45 | info.commandSender 46 | ); 47 | }, 48 | }); 49 | 50 | DraupnirInterfaceAdaptor.describeRenderer(SynpaseAdminUnrestrictUserCommand, { 51 | isAlwaysSupposedToUseDefaultRenderer: true, 52 | }); 53 | -------------------------------------------------------------------------------- /src/capabilities/RendererMessageCollector.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { DocumentNode } from "@the-draupnir-project/interface-manager"; 6 | import { Capability, DescriptionMeta } from "matrix-protection-suite"; 7 | 8 | export enum MessageType { 9 | Document = "Document", 10 | OneLine = "OneLine", 11 | SingleEffectError = "SingleEffectError", 12 | } 13 | 14 | export interface RendererMessageCollector { 15 | addMessage( 16 | protection: DescriptionMeta, 17 | capability: Capability, 18 | message: DocumentNode 19 | ): void; 20 | addOneliner( 21 | protection: DescriptionMeta, 22 | capability: Capability, 23 | message: DocumentNode 24 | ): void; 25 | getMessages(): RendererMessage[]; 26 | } 27 | 28 | export interface RendererMessage { 29 | protection: DescriptionMeta; 30 | capability: Capability; 31 | message: DocumentNode; 32 | type: MessageType; 33 | } 34 | 35 | /** 36 | * Used by capabilities to send messages to the users of Draupnir. 37 | */ 38 | export class AbstractRendererMessageCollector 39 | implements RendererMessageCollector 40 | { 41 | private readonly messages: RendererMessage[] = []; 42 | public getMessages(): RendererMessage[] { 43 | return this.messages; 44 | } 45 | addMessage( 46 | protection: DescriptionMeta, 47 | capability: Capability, 48 | message: DocumentNode 49 | ): void { 50 | this.messages.push({ 51 | protection, 52 | capability, 53 | message, 54 | type: MessageType.Document, 55 | }); 56 | } 57 | 58 | addOneliner( 59 | protection: DescriptionMeta, 60 | capability: Capability, 61 | message: DocumentNode 62 | ): void { 63 | this.messages.push({ 64 | protection, 65 | capability, 66 | message, 67 | type: MessageType.OneLine, 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.github/workflows/docker-hub-latest.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Gnuxie 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | # Copied from https://github.com/matrix-org/matrix-bifrost/blob/develop/.github/workflows/docker-hub-release.yml 6 | # This is duplicated with docker-hub-release.yml the only difference is the tag and hook 7 | 8 | name: "Docker Hub - Latest" 9 | 10 | on: 11 | release: 12 | types: [released] 13 | 14 | env: 15 | DOCKER_NAMESPACE: gnuxie 16 | PLATFORMS: linux/amd64,linux/arm64 17 | 18 | jobs: 19 | docker-release: 20 | runs-on: ubuntu-latest 21 | permissions: 22 | id-token: write 23 | packages: write 24 | contents: read 25 | attestations: write 26 | steps: 27 | - name: Check out 28 | uses: actions/checkout@v4 29 | - name: Get release tag 30 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 31 | - name: Unshallow for git describe so we can create version.txt 32 | run: git fetch --prune --unshallow --tags --all --force 33 | # Needed for multi platform builds 34 | - name: Set up QEMU 35 | uses: docker/setup-qemu-action@v3 36 | with: 37 | platforms: ${{ env.PLATFORMS }} 38 | 39 | - name: Set up Docker Buildx 40 | uses: docker/setup-buildx-action@v3 41 | 42 | - name: Log in to Docker Hub 43 | uses: docker/login-action@v3 44 | with: 45 | username: ${{ secrets.DOCKERHUB_USERNAME }} 46 | password: ${{ secrets.DOCKERHUB_TOKEN }} 47 | 48 | - name: Build image 49 | id: push 50 | uses: docker/build-push-action@v5 51 | with: 52 | context: . 53 | file: ./Dockerfile 54 | platforms: ${{ env.PLATFORMS }} 55 | push: true 56 | sbom: true 57 | tags: | 58 | ${{ env.DOCKER_NAMESPACE }}/draupnir:latest 59 | -------------------------------------------------------------------------------- /.github/workflows/docker-hub-release.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Gnuxie 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | # Copied from https://github.com/matrix-org/matrix-bifrost/blob/develop/.github/workflows/docker-hub-release.yml 6 | # This is duplicated with docker-hub-latest.yml the only difference is the tag and hook 7 | 8 | name: "Docker Hub - Release" 9 | 10 | on: 11 | release: 12 | types: [published] 13 | 14 | env: 15 | DOCKER_NAMESPACE: gnuxie 16 | PLATFORMS: linux/amd64,linux/arm64 17 | 18 | jobs: 19 | docker-release: 20 | runs-on: ubuntu-latest 21 | permissions: 22 | id-token: write 23 | packages: write 24 | contents: read 25 | attestations: write 26 | steps: 27 | - name: Check out 28 | uses: actions/checkout@v4 29 | - name: Get release tag 30 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 31 | - name: Unshallow for git describe so we can create version.txt 32 | run: git fetch --prune --unshallow --tags --all --force 33 | 34 | # Needed for multi platform builds 35 | - name: Set up QEMU 36 | uses: docker/setup-qemu-action@v3 37 | with: 38 | platforms: ${{ env.PLATFORMS }} 39 | 40 | - name: Set up Docker Buildx 41 | uses: docker/setup-buildx-action@v3 42 | 43 | - name: Log in to Docker Hub 44 | uses: docker/login-action@v3 45 | with: 46 | username: ${{ secrets.DOCKERHUB_USERNAME }} 47 | password: ${{ secrets.DOCKERHUB_TOKEN }} 48 | 49 | - name: Build image 50 | id: push 51 | uses: docker/build-push-action@v5 52 | with: 53 | context: . 54 | file: ./Dockerfile 55 | platforms: ${{ env.PLATFORMS }} 56 | push: true 57 | sbom: true 58 | tags: | 59 | ${{ env.DOCKER_NAMESPACE }}/draupnir:${{ env.RELEASE_VERSION }} 60 | -------------------------------------------------------------------------------- /test/nginx.conf: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Gnuxie 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | events { 6 | 7 | } 8 | 9 | http { 10 | server { 11 | listen [::]:8081 ipv6only=off; 12 | 13 | location ~ ^/_matrix/client/(r0|v3)/rooms/([^/]*)/report/(.*)$ { 14 | mirror /report_mirror; 15 | # Abuse reports should be sent to Draupnir. 16 | # The r0 endpoint is deprecated but still used by many clients. 17 | # As of this writing, the v3 endpoint is the up-to-date version. 18 | 19 | # Add CORS, otherwise a browser will refuse this request. 20 | add_header 'Access-Control-Allow-Origin' '*' always; # Note: '*' is for testing purposes. For your own server, you probably want to tighten this. 21 | add_header 'Access-Control-Allow-Credentials' 'true' always; 22 | add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; 23 | add_header 'Access-Control-Allow-Headers' 'Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,X-Mx-ReqToken,Keep-Alive,X-Requested-With,If-Modified-Since' always; 24 | add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; 25 | add_header 'Access-Control-Max-Age' 1728000; # cache preflight value for 20 days 26 | 27 | # Alias the regexps, to ensure that they're not rewritten. 28 | set $room_id $2; 29 | set $event_id $3; 30 | proxy_pass http://127.0.0.1:8082/api/1/report/$room_id/$event_id; 31 | } 32 | location / { 33 | # Everything else should be sent to Synapse. 34 | proxy_pass http://127.0.0.1:9999; 35 | } 36 | location /report_mirror { 37 | internal; 38 | proxy_pass http://127.0.0.1:9999$request_uri; 39 | } 40 | # draupnir news blob 41 | location /draupnir_news.json { 42 | root /var/www/test; 43 | default_type application/json; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/appservice/integration/safeModeRecoverTest.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; 6 | import { MjolnirAppService } from "../../../src/appservice/AppService"; 7 | import { newTestUser } from "../../integration/clientHelper"; 8 | import { StandardProvisionHelper } from "../utils/ProvisionHelper"; 9 | import { setupHarness } from "../utils/harness"; 10 | import { testRecoverAndRestart } from "../../integration/commands/recoverCommandDetail"; 11 | 12 | interface Context extends Mocha.Context { 13 | appservice?: MjolnirAppService; 14 | } 15 | 16 | describe("Test safe mode recovery on a provisioned Draupnir", function () { 17 | beforeEach(async function (this: Context) { 18 | this.appservice = await setupHarness(); 19 | }); 20 | afterEach(function (this: Context) { 21 | if (this.appservice) { 22 | return this.appservice.close(); 23 | } else { 24 | console.warn("Missing Appservice in this context, so cannot stop it."); 25 | return Promise.resolve(); // TS7030: Not all code paths return a value. 26 | } 27 | }); 28 | it("Provisioned draupnir can be recovered from safe mode.", async function (this: Context) { 29 | const appservice = this.appservice; 30 | if (appservice === undefined) { 31 | throw new TypeError(`Test setup failed`); 32 | } 33 | const provisionHelper = new StandardProvisionHelper(appservice); 34 | const moderator = await newTestUser(appservice.config.homeserver.url, { 35 | name: { contains: "moderator" }, 36 | }); 37 | const moderatorUserID = (await moderator.getUserId()) as StringUserID; 38 | const initialDraupnir = ( 39 | await provisionHelper.provisionDraupnir(moderatorUserID) 40 | ).expect("Failed to provision a draupnir for the test"); 41 | await testRecoverAndRestart(moderatorUserID, initialDraupnir); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/appservice/bot/AccessCommands.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { AppserviceAdaptorContext } from "./AppserviceBotPrerequisite"; 6 | import { ActionResult } from "matrix-protection-suite"; 7 | import { 8 | MatrixUserIDPresentationType, 9 | describeCommand, 10 | tuple, 11 | } from "@the-draupnir-project/interface-manager"; 12 | import { AppserviceBotInterfaceAdaptor } from "./AppserviceBotInterfaceAdaptor"; 13 | 14 | export const AppserviceAllowCommand = describeCommand({ 15 | parameters: tuple({ 16 | name: "user", 17 | acceptor: MatrixUserIDPresentationType, 18 | description: "The user that should be allowed to provision a bot", 19 | }), 20 | summary: 21 | "Allow a user to provision themselves a draupnir using the appservice.", 22 | async executor( 23 | context: AppserviceAdaptorContext, 24 | _info, 25 | _keywords, 26 | _rest, 27 | user 28 | ): Promise> { 29 | return await context.appservice.accessControl.allow(user.toString()); 30 | }, 31 | }); 32 | 33 | AppserviceBotInterfaceAdaptor.describeRenderer(AppserviceAllowCommand, { 34 | isAlwaysSupposedToUseDefaultRenderer: true, 35 | }); 36 | 37 | export const AppserviceRemoveCommand = describeCommand({ 38 | parameters: tuple({ 39 | name: "user", 40 | acceptor: MatrixUserIDPresentationType, 41 | description: 42 | "The user which shall not be allowed to provision bots anymore", 43 | }), 44 | summary: "Stop a user from using any provisioned draupnir in the appservice.", 45 | async executor( 46 | context: AppserviceAdaptorContext, 47 | _info, 48 | _keywords, 49 | _rest, 50 | user 51 | ): Promise> { 52 | return await context.appservice.accessControl.remove(user.toString()); 53 | }, 54 | }); 55 | 56 | AppserviceBotInterfaceAdaptor.describeRenderer(AppserviceRemoveCommand, { 57 | isAlwaysSupposedToUseDefaultRenderer: true, 58 | }); 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Gnuxie 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | # Draupnir config 6 | config/* 7 | !config/default.yaml 8 | 9 | # Draupnir test data 10 | draupnir-registration.yaml 11 | test/harness/mjolnir-data/* 12 | 13 | # Misc 14 | 15 | lib/ 16 | storage/ 17 | venv/ 18 | 19 | /.idea 20 | 21 | /db 22 | 23 | # version file generated from yarn build 24 | version.txt 25 | # Logs 26 | logs 27 | *.log 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # Runtime data 33 | pids 34 | *.pid 35 | *.seed 36 | *.pid.lock 37 | 38 | # Directory for instrumented libs generated by jscoverage/JSCover 39 | lib-cov 40 | 41 | # Coverage directory used by tools like istanbul 42 | coverage 43 | 44 | # nyc test coverage 45 | .nyc_output 46 | 47 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 48 | .grunt 49 | 50 | # Bower dependency directory (https://bower.io/) 51 | bower_components 52 | 53 | # node-waf configuration 54 | .lock-wscript 55 | 56 | # Compiled binary addons (https://nodejs.org/api/addons.html) 57 | build/Release 58 | 59 | # Dependency directories 60 | node_modules/ 61 | jspm_packages/ 62 | 63 | # TypeScript v1 declaration files 64 | typings/ 65 | 66 | # Optional npm cache directory 67 | .npm 68 | 69 | # Optional eslint cache 70 | .eslintcache 71 | 72 | # Optional REPL history 73 | .node_repl_history 74 | 75 | # Output of 'npm pack' 76 | *.tgz 77 | 78 | # Yarn Integrity file 79 | .yarn-integrity 80 | 81 | # dotenv environment variables file 82 | .env 83 | 84 | # next.js build output 85 | .next 86 | 87 | # Python packing directories. 88 | mjolnir.egg-info/ 89 | 90 | # VS 91 | .vs 92 | 93 | # Yarn versions through corepack 94 | .yarn 95 | # Only contains possibly broken path, replaced by .yarnrc.yml in modern Yarn 96 | .yarnrc 97 | 98 | # VS Code workspace settings 99 | /.vscode 100 | -------------------------------------------------------------------------------- /test/unit/protections/MentionLimitProtectionTest.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Gnuxie 2 | // Copyright 2024 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: Apache-2.0 5 | 6 | import { 7 | DefaultMixinExtractor, 8 | EventWithMixins, 9 | randomRoomID, 10 | randomUserID, 11 | RoomEvent, 12 | } from "matrix-protection-suite"; 13 | import { isContainingMentionsOverLimit } from "../../../src/protections/MentionLimitProtection"; 14 | import expect from "expect"; 15 | 16 | function messageEvent(content: { 17 | body?: string; 18 | formatted_body?: string; 19 | "m.mentions"?: { user_ids: string[] }; 20 | }): EventWithMixins { 21 | return DefaultMixinExtractor.parseEvent({ 22 | content, 23 | type: "m.room.message", 24 | sender: randomUserID(), 25 | room_id: randomRoomID([]).toRoomIDOrAlias(), 26 | } as RoomEvent); 27 | } 28 | 29 | describe("MentionLimitProtection test", function () { 30 | it("Allows normal events", function () { 31 | expect( 32 | isContainingMentionsOverLimit( 33 | messageEvent({ body: "Hello", formatted_body: "Hello" }), 34 | 1, 35 | true 36 | ) 37 | ).toBe(false); 38 | }); 39 | it("Detects mentions in the body", function () { 40 | expect( 41 | isContainingMentionsOverLimit( 42 | messageEvent({ body: "Hello @admin:example.com" }), 43 | 0, 44 | true 45 | ) 46 | ).toBe(true); 47 | }); 48 | it("Detects mentions from m.mentions", function () { 49 | expect( 50 | isContainingMentionsOverLimit( 51 | messageEvent({ "m.mentions": { user_ids: ["@admin:example.com"] } }), 52 | 0, 53 | true 54 | ) 55 | ).toBe(true); 56 | }); 57 | it("Allows mentions under the limit", function () { 58 | expect( 59 | isContainingMentionsOverLimit( 60 | messageEvent({ "m.mentions": { user_ids: ["@admin:example.com"] } }), 61 | 1, 62 | true 63 | ) 64 | ).toBe(false); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/capabilities/DraupnirRendererMessageCollector.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { 6 | RendererMessage, 7 | RendererMessageCollector, 8 | } from "./RendererMessageCollector"; 9 | import { 10 | Capability, 11 | DescriptionMeta, 12 | RoomMessageSender, 13 | Task, 14 | } from "matrix-protection-suite"; 15 | import { StringRoomID } from "@the-draupnir-project/matrix-basic-types"; 16 | import { 17 | DeadDocumentJSX, 18 | DocumentNode, 19 | } from "@the-draupnir-project/interface-manager"; 20 | import { sendMatrixEventsFromDeadDocument } from "@the-draupnir-project/mps-interface-adaptor"; 21 | 22 | export class DraupnirRendererMessageCollector 23 | implements RendererMessageCollector 24 | { 25 | constructor( 26 | private readonly roomMessageSender: RoomMessageSender, 27 | private readonly managementRoomID: StringRoomID 28 | ) { 29 | // nothing to do. 30 | } 31 | private sendMessage(capability: Capability, document: DocumentNode): void { 32 | void Task( 33 | sendMatrixEventsFromDeadDocument( 34 | this.roomMessageSender, 35 | this.managementRoomID, 36 | 37 | {capability.isSimulated ? ( 38 | ⚠️ (preview) 39 | ) : ( 40 | 41 | )} 42 | {document} 43 | , 44 | {} 45 | ) 46 | ); 47 | } 48 | addMessage( 49 | protection: DescriptionMeta, 50 | capability: Capability, 51 | message: DocumentNode 52 | ): void { 53 | this.sendMessage(capability, message); 54 | } 55 | addOneliner( 56 | protection: DescriptionMeta, 57 | capability: Capability, 58 | message: DocumentNode 59 | ): void { 60 | this.sendMessage( 61 | capability, 62 | 63 | {protection.name}: {message} 64 | 65 | ); 66 | } 67 | getMessages(): RendererMessage[] { 68 | return []; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/protections/DraupnirNews/news.json: -------------------------------------------------------------------------------- 1 | { 2 | "news": [ 3 | { 4 | "news_id": "cd1881d2-60d0-49ad-9951-321205efa64b", 5 | "matrix_event_content": { 6 | "msgtype": "m.notice", 7 | "body": "#### 📰 Draupnir Assembly: Call for Participation\n\nThe Longhouse Assembly is discussing the next direction of the project.\n\nIf you value what the Draupnir project does for your community, then we really want to hear from you:\n\n- ➡️ [Read about the current longhouse cycle](https://the-draupnir-project.github.io/draupnir-documentation/governance/reports/2510A-cycle-review)\n- ➡️ [Read about the pathways going forward](https://the-draupnir-project.github.io/draupnir-documentation/governance/reports/2510A-selection)\n- ➡️ [Cast your vote](https://cryptpad.fr/form/#/2/form/view/ewtgdO-YIwCjLhfJpwsj87m7RU7v6hJKHbu3BWqa1kg/)\n- ➡️ [Join the Assembly Discussion](https://matrix.to/#/!UMROhYUQcvtGuoIIka:matrix.org/%247C2Sv-B-6HJ7fVMlCRd3R9jlZqe2rUxlPliEaB-M4yE?via=matrix.org&via=feline.support&via=asgard.chat)", 8 | "format": "org.matrix.custom.html", 9 | "formatted_body": "

📰 Draupnir Assembly: Call for Participation

\n

The Longhouse Assembly is discussing the next direction of the project.

\n

If you value what the Draupnir project does for your community, then we really want to hear from you:

\n\n" 10 | } 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /test/appservice/utils/harness.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // Copyright 2022 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import path from "path"; 12 | import { MjolnirAppService } from "../../../src/appservice/AppService"; 13 | import { ensureAliasedRoomExists } from "../../integration/mjolnirSetupUtils"; 14 | import { 15 | read as configRead, 16 | AppserviceConfig, 17 | } from "../../../src/appservice/config/config"; 18 | import { newTestUser } from "../../integration/clientHelper"; 19 | import { CreateEvent, MatrixClient } from "matrix-bot-sdk"; 20 | import { POLICY_ROOM_TYPE_VARIANTS } from "matrix-protection-suite"; 21 | import { isStringRoomAlias } from "@the-draupnir-project/matrix-basic-types"; 22 | 23 | export function readTestConfig(): AppserviceConfig { 24 | return configRead( 25 | path.join(__dirname, "../../../src/appservice/config/config.harness.yaml") 26 | ); 27 | } 28 | 29 | export async function setupHarness(): Promise { 30 | const config = readTestConfig(); 31 | const utilityUser = await newTestUser(config.homeserver.url, { 32 | name: { contains: "utility" }, 33 | }); 34 | if ( 35 | typeof config.adminRoom !== "string" || 36 | !isStringRoomAlias(config.adminRoom) 37 | ) { 38 | throw new TypeError( 39 | "This test expects the harness config to have a room alias." 40 | ); 41 | } 42 | await ensureAliasedRoomExists(utilityUser, config.adminRoom); 43 | return await MjolnirAppService.run( 44 | 9000, 45 | config, 46 | "draupnir-registration.yaml" 47 | ); 48 | } 49 | 50 | export async function isPolicyRoom( 51 | user: MatrixClient, 52 | roomId: string 53 | ): Promise { 54 | const createEvent = new CreateEvent( 55 | await user.getRoomStateEvent(roomId, "m.room.create", "") 56 | ); 57 | return POLICY_ROOM_TYPE_VARIANTS.includes(createEvent.type); 58 | } 59 | -------------------------------------------------------------------------------- /src/appservice/postgres/PgDataStore.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Matrix.org Foundation C.I.C. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | import { PostgresStore, SchemaUpdateFunction } from "matrix-appservice-bridge"; 6 | import { DataStore, MjolnirRecord } from "../datastore"; 7 | 8 | function getSchema(): SchemaUpdateFunction[] { 9 | const nSchema = 2; 10 | const schema = []; 11 | for (let schemaID = 1; schemaID < nSchema + 1; schemaID++) { 12 | // eslint-disable-next-line @typescript-eslint/no-var-requires 13 | schema.push(require(`./schema/v${schemaID}`).runSchema); 14 | } 15 | return schema; 16 | } 17 | 18 | export class PgDataStore extends PostgresStore implements DataStore { 19 | constructor(connectionString: string) { 20 | super(getSchema(), { url: connectionString }); 21 | } 22 | 23 | public async init(): Promise { 24 | await this.ensureSchema(); 25 | } 26 | 27 | public async close(): Promise { 28 | await this.destroy(); 29 | } 30 | 31 | public async list(): Promise { 32 | const result = await this 33 | .sql`SELECT local_part, owner, management_room FROM draupnir`; 34 | if (!result.count) { 35 | return []; 36 | } 37 | 38 | return result.flat() as MjolnirRecord[]; 39 | } 40 | 41 | public async store(mjolnirRecord: MjolnirRecord): Promise { 42 | await this.sql`INSERT INTO draupnir (local_part, owner, management_room) 43 | VALUES (${mjolnirRecord.local_part}, ${mjolnirRecord.owner}, ${mjolnirRecord.management_room})`; 44 | } 45 | 46 | public async lookupByOwner(owner: string): Promise { 47 | const result = await this 48 | .sql`SELECT local_part, owner, management_room FROM draupnir 49 | WHERE owner = ${owner}`; 50 | return result.flat() as MjolnirRecord[]; 51 | } 52 | 53 | public async lookupByLocalPart(localPart: string): Promise { 54 | const result = await this 55 | .sql`SELECT local_part, owner, management_room FROM draupnir 56 | WHERE local_part = ${localPart}`; 57 | return result.flat() as MjolnirRecord[]; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /test/integration/commands/shutdownCommandTest.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // Copyright 2022 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { strict as assert } from "assert"; 12 | 13 | import { newTestUser } from "../clientHelper"; 14 | import { 15 | DraupnirTestContext, 16 | draupnirClient, 17 | draupnirSafeEmitter, 18 | } from "../mjolnirSetupUtils"; 19 | import { MatrixClient, MatrixError } from "matrix-bot-sdk"; 20 | import { getFirstReaction } from "./commandUtils"; 21 | 22 | describe("Test: shutdown command", function () { 23 | let client: MatrixClient; 24 | this.beforeEach(async function () { 25 | client = await newTestUser(this.config.homeserverUrl, { 26 | name: { contains: "shutdown-command" }, 27 | }); 28 | await client.start(); 29 | }); 30 | this.afterEach(async function () { 31 | client.stop(); 32 | }); 33 | it("Draupnir asks synapse to shut down a channel", async function ( 34 | this: DraupnirTestContext 35 | ) { 36 | this.timeout(20000); 37 | const badRoom = await client.createRoom(); 38 | const draupnir = this.draupnir; 39 | const draupnirMatrixClient = draupnirClient(); 40 | if (draupnir === undefined || draupnirMatrixClient === null) { 41 | throw new TypeError(`setup code is wrong`); 42 | } 43 | await client.joinRoom(draupnir.managementRoomID); 44 | 45 | await getFirstReaction( 46 | draupnirSafeEmitter(), 47 | draupnir.managementRoomID, 48 | "✅", 49 | async () => 50 | await client.sendMessage(draupnir.managementRoomID, { 51 | msgtype: "m.text", 52 | body: `!draupnir shutdown room ${badRoom} closure test`, 53 | }) 54 | ); 55 | 56 | await assert.rejects(client.joinRoom(badRoom), (e: MatrixError) => { 57 | assert.equal(e.statusCode, 403); 58 | assert.equal(e.body.error, "This room has been blocked on this server"); 59 | return true; 60 | }); 61 | } as unknown as Mocha.AsyncFunc); 62 | }); 63 | -------------------------------------------------------------------------------- /test/integration/helloTest.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 - 2024 Gnuxie 2 | // Copyright 2021 - 2022 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { MatrixClient } from "matrix-bot-sdk"; 12 | import { newTestUser, noticeListener } from "./clientHelper"; 13 | import { DraupnirTestContext } from "./mjolnirSetupUtils"; 14 | import { SafeMatrixEmitterWrapper } from "matrix-protection-suite-for-matrix-bot-sdk"; 15 | import { DefaultEventDecoder } from "matrix-protection-suite"; 16 | 17 | describe("Test: !help command", function () { 18 | let client: MatrixClient; 19 | this.beforeEach(async function (this: DraupnirTestContext) { 20 | client = await newTestUser(this.config.homeserverUrl, { 21 | name: { contains: "-" }, 22 | }); 23 | await client.start(); 24 | } as unknown as Mocha.AsyncFunc); 25 | this.afterEach(async function () { 26 | client.stop(); 27 | } as unknown as Mocha.AsyncFunc); 28 | it("Draupnir responded to !mjolnir help", async function ( 29 | this: DraupnirTestContext 30 | ) { 31 | this.timeout(30000); 32 | // send a messgage 33 | const draupnir = this.draupnir; 34 | const clientEmitter = new SafeMatrixEmitterWrapper( 35 | client, 36 | DefaultEventDecoder 37 | ); 38 | if (draupnir === undefined) { 39 | throw new TypeError(`setup code is wrong`); 40 | } 41 | await client.joinRoom(this.config.managementRoom); 42 | // listener for getting the event reply 43 | const reply = new Promise((resolve) => { 44 | clientEmitter.on( 45 | "room.message", 46 | noticeListener(draupnir.managementRoomID, (event) => { 47 | if (event.content.body.includes("which can be used")) { 48 | resolve(event); 49 | } 50 | }) 51 | ); 52 | }); 53 | await client.sendMessage(draupnir.managementRoomID, { 54 | msgtype: "m.text", 55 | body: "!draupnir help", 56 | }); 57 | await reply; 58 | } as unknown as Mocha.AsyncFunc); 59 | }); 60 | -------------------------------------------------------------------------------- /src/safemode/SafeModeCommandDispatcher.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { 6 | BasicInvocationInformation, 7 | JSInterfaceCommandDispatcher, 8 | MatrixInterfaceCommandDispatcher, 9 | StandardJSInterfaceCommandDispatcher, 10 | StandardMatrixInterfaceCommandDispatcher, 11 | } from "@the-draupnir-project/interface-manager"; 12 | import { SafeModeDraupnir } from "./DraupnirSafeMode"; 13 | import { 14 | MPSCommandDispatcherCallbacks, 15 | MatrixEventContext, 16 | invocationInformationFromMatrixEventcontext, 17 | } from "@the-draupnir-project/mps-interface-adaptor"; 18 | import { SafeModeCommands } from "./commands/SafeModeCommands"; 19 | import { SafeModeHelpCommand } from "./commands/HelpCommand"; 20 | import { 21 | SafeModeContextToCommandContextTranslator, 22 | SafeModeInterfaceAdaptor, 23 | } from "./commands/SafeModeAdaptor"; 24 | import { makeDraupnirCommandNormaliser } from "../commands/DraupnirCommandDispatcher"; 25 | 26 | export function makeSafeModeCommandDispatcher( 27 | safeModeDraupnir: SafeModeDraupnir 28 | ): MatrixInterfaceCommandDispatcher { 29 | return new StandardMatrixInterfaceCommandDispatcher( 30 | SafeModeInterfaceAdaptor, 31 | safeModeDraupnir, 32 | SafeModeCommands, 33 | SafeModeHelpCommand, 34 | invocationInformationFromMatrixEventcontext, 35 | { 36 | ...MPSCommandDispatcherCallbacks, 37 | commandNormaliser: makeDraupnirCommandNormaliser( 38 | safeModeDraupnir.clientUserID, 39 | safeModeDraupnir, 40 | safeModeDraupnir.config 41 | ), 42 | } 43 | ); 44 | } 45 | 46 | export function makeSafeModeJSDispatcher( 47 | safeModeDraupnir: SafeModeDraupnir 48 | ): JSInterfaceCommandDispatcher { 49 | return new StandardJSInterfaceCommandDispatcher( 50 | SafeModeCommands, 51 | SafeModeHelpCommand, 52 | safeModeDraupnir, 53 | { 54 | ...MPSCommandDispatcherCallbacks, 55 | commandNormaliser: makeDraupnirCommandNormaliser( 56 | safeModeDraupnir.clientUserID, 57 | safeModeDraupnir, 58 | safeModeDraupnir.config 59 | ), 60 | }, 61 | SafeModeContextToCommandContextTranslator 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /test/appservice/integration/safeModeToggleTest.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; 6 | import { MjolnirAppService } from "../../../src/appservice/AppService"; 7 | import { newTestUser } from "../../integration/clientHelper"; 8 | import { StandardProvisionHelper } from "../utils/ProvisionHelper"; 9 | import { setupHarness } from "../utils/harness"; 10 | import { SafeModeDraupnir } from "../../../src/safemode/DraupnirSafeMode"; 11 | 12 | interface Context extends Mocha.Context { 13 | appservice?: MjolnirAppService; 14 | } 15 | 16 | describe("Test safe mode commands on a provisioned Draupnir", function () { 17 | beforeEach(async function (this: Context) { 18 | this.appservice = await setupHarness(); 19 | }); 20 | afterEach(function (this: Context) { 21 | if (this.appservice) { 22 | return this.appservice.close(); 23 | } else { 24 | console.warn("Missing Appservice in this context, so cannot stop it."); 25 | return Promise.resolve(); // TS7030: Not all code paths return a value. 26 | } 27 | }); 28 | it("Provisioned draupnir can switch to safe mode and back.", async function (this: Context) { 29 | const appservice = this.appservice; 30 | if (appservice === undefined) { 31 | throw new TypeError(`Test setup failed`); 32 | } 33 | const provisionHelper = new StandardProvisionHelper(appservice); 34 | const moderator = await newTestUser(appservice.config.homeserver.url, { 35 | name: { contains: "moderator" }, 36 | }); 37 | const moderatorUserID = (await moderator.getUserId()) as StringUserID; 38 | const initialDraupnir = ( 39 | await provisionHelper.provisionDraupnir(moderatorUserID) 40 | ).expect("Failed to provision a draupnir for the test"); 41 | const safeModeDraupnir = ( 42 | await initialDraupnir.sendTextCommand( 43 | moderatorUserID, 44 | "!draupnir safe mode" 45 | ) 46 | ).expect("Failed to switch to safe mode"); 47 | ( 48 | await safeModeDraupnir.sendTextCommand( 49 | moderatorUserID, 50 | "!draupnir restart" 51 | ) 52 | ).expect("Failed to restart back to draupnir from safe mode"); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /test/integration/throttleTest.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 - 2024 Gnuxie 2 | // Copyright 2021 - 2022 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { strict as assert } from "assert"; 12 | import { newTestUser } from "./clientHelper"; 13 | import { getMessagesByUserIn } from "../../src/utils"; 14 | 15 | describe("Test: throttled users can function with Draupnir.", function () { 16 | it("throttled users survive being throttled by synapse", async function () { 17 | const throttledUser = await newTestUser(this.config.homeserverUrl, { 18 | name: { contains: "throttled" }, 19 | isThrottled: true, 20 | }); 21 | const throttledUserId = await throttledUser.getUserId(); 22 | const targetRoom = await throttledUser.createRoom(); 23 | // send enough messages to hit the rate limit. 24 | await Promise.all( 25 | [...Array(25).keys()].map((i) => 26 | throttledUser.sendMessage(targetRoom, { 27 | msgtype: "m.text.", 28 | body: `Message #${i}`, 29 | }) 30 | ) 31 | ); 32 | let messageCount = 0; 33 | await getMessagesByUserIn( 34 | throttledUser, 35 | throttledUserId, 36 | targetRoom, 37 | 25, 38 | (events) => { 39 | messageCount += events.length; 40 | } 41 | ); 42 | assert.equal( 43 | messageCount, 44 | 25, 45 | "There should have been 25 messages in this room" 46 | ); 47 | }); 48 | }); 49 | 50 | /** 51 | * We used to have a test here that tested whether Draupnir was going to carry out a redact order the default limits in a reasonable time scale. 52 | * Now I think that's never going to happen without writing a new algorithm for respecting rate limiting. 53 | * Which is not something there is time for. 54 | * 55 | * https://github.com/matrix-org/synapse/pull/13018 56 | * 57 | * Synapse rate limits were broken and very permitting so that's why the current hack worked so well. 58 | * Now it is not broken, so our rate limit handling is. 59 | * 60 | * https://github.com/matrix-org/mjolnir/commit/b850e4554c6cbc9456e23ab1a92ede547d044241 61 | * 62 | * Honestly I don't think we can expect anyone to be able to use Draupnir under default rate limits. 63 | */ 64 | -------------------------------------------------------------------------------- /src/commands/CreateBanListCommand.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // Copyright 2019 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { ActionResult, isError } from "matrix-protection-suite"; 12 | import { Draupnir } from "../Draupnir"; 13 | import { MatrixRoomID } from "@the-draupnir-project/matrix-basic-types"; 14 | import { 15 | BasicInvocationInformation, 16 | ParsedKeywords, 17 | StringPresentationType, 18 | describeCommand, 19 | tuple, 20 | } from "@the-draupnir-project/interface-manager"; 21 | import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites"; 22 | 23 | export async function createList( 24 | draupnir: Draupnir, 25 | info: BasicInvocationInformation, 26 | _keywords: ParsedKeywords, 27 | _rest: undefined[], 28 | shortcode: string, 29 | aliasName: string 30 | ): Promise> { 31 | const newList = await draupnir.policyRoomManager.createPolicyRoom( 32 | shortcode, 33 | // avoids inviting ourself and setting 50 as our own powerlevel 34 | [info.commandSender].filter((sender) => sender !== draupnir.clientUserID), 35 | { 36 | room_alias_name: aliasName, 37 | } 38 | ); 39 | if (isError(newList)) { 40 | return newList; 41 | } 42 | const watchResult = 43 | await draupnir.protectedRoomsSet.watchedPolicyRooms.watchPolicyRoomDirectly( 44 | newList.ok 45 | ); 46 | if (isError(watchResult)) { 47 | return watchResult; 48 | } 49 | const protectResult = 50 | await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom(newList.ok); 51 | if (isError(protectResult)) { 52 | return protectResult; 53 | } 54 | return newList; 55 | } 56 | 57 | export const DraupnirListCreateCommand = describeCommand({ 58 | summary: 59 | "Create a new Policy Room which can be used to ban users, rooms and servers from your protected rooms", 60 | parameters: tuple( 61 | { 62 | name: "shortcode", 63 | acceptor: StringPresentationType, 64 | }, 65 | { 66 | name: "alias name", 67 | acceptor: StringPresentationType, 68 | } 69 | ), 70 | executor: createList, 71 | }); 72 | 73 | DraupnirInterfaceAdaptor.describeRenderer(DraupnirListCreateCommand, { 74 | isAlwaysSupposedToUseDefaultRenderer: true, 75 | }); 76 | -------------------------------------------------------------------------------- /src/commands/server-admin/HijackRoomCommand.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 - 2024 Gnuxie 2 | // Copyright 2021, 2022 Marco Cirillo 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { 12 | ActionError, 13 | ActionResult, 14 | Ok, 15 | isError, 16 | } from "matrix-protection-suite"; 17 | import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; 18 | import { 19 | MatrixRoomReferencePresentationSchema, 20 | MatrixUserIDPresentationType, 21 | describeCommand, 22 | tuple, 23 | } from "@the-draupnir-project/interface-manager"; 24 | import { Draupnir } from "../../Draupnir"; 25 | import { DraupnirInterfaceAdaptor } from "../DraupnirCommandPrerequisites"; 26 | 27 | export const SynapseAdminHijackRoomCommand = describeCommand({ 28 | summary: 29 | "Make the specified user the admin of a room via the synapse admin API", 30 | parameters: tuple( 31 | { 32 | name: "room", 33 | acceptor: MatrixRoomReferencePresentationSchema, 34 | }, 35 | { 36 | name: "user", 37 | acceptor: MatrixUserIDPresentationType, 38 | } 39 | ), 40 | async executor( 41 | draupnir: Draupnir, 42 | _info, 43 | _keywords, 44 | _rest, 45 | room, 46 | user 47 | ): Promise> { 48 | const isAdmin = await draupnir.synapseAdminClient?.isSynapseAdmin(); 49 | if ( 50 | !draupnir.config.admin?.enableMakeRoomAdminCommand || 51 | isAdmin === undefined || 52 | isError(isAdmin) || 53 | !isAdmin.ok 54 | ) { 55 | return ActionError.Result( 56 | "Either the command is disabled or Draupnir is not running as homeserver administrator." 57 | ); 58 | } 59 | if (draupnir.synapseAdminClient === undefined) { 60 | throw new TypeError("Should be impossible at this point"); 61 | } 62 | const resolvedRoom = await resolveRoomReferenceSafe(draupnir.client, room); 63 | if (isError(resolvedRoom)) { 64 | return resolvedRoom; 65 | } 66 | await draupnir.synapseAdminClient.makeUserRoomAdmin( 67 | resolvedRoom.ok.toRoomIDOrAlias(), 68 | user.toString() 69 | ); 70 | return Ok(undefined); 71 | }, 72 | }); 73 | 74 | DraupnirInterfaceAdaptor.describeRenderer(SynapseAdminHijackRoomCommand, { 75 | isAlwaysSupposedToUseDefaultRenderer: true, 76 | }); 77 | -------------------------------------------------------------------------------- /test/appservice/utils/webAPIClient.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // Copyright 2022 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import * as request from "request"; 12 | import { MatrixClient } from "matrix-bot-sdk"; 13 | 14 | interface OpenIDTokenInfo { 15 | access_token: string; 16 | expires_in: number; 17 | matrix_server_name: string; 18 | token_type: string; 19 | } 20 | 21 | async function getOpenIDToken(client: MatrixClient): Promise { 22 | const tokenInfo: OpenIDTokenInfo = await client.doRequest( 23 | "POST", 24 | `/_matrix/client/v3/user/${await client.getUserId()}/openid/request_token`, 25 | undefined, 26 | {} 27 | ); 28 | return tokenInfo.access_token; 29 | } 30 | 31 | export interface CreateMjolnirResponse { 32 | mjolnirUserId: string; 33 | managementRoomId: string; 34 | } 35 | 36 | export class MjolnirWebAPIClient { 37 | private constructor( 38 | private readonly openIDToken: string, 39 | private readonly baseURL: string 40 | ) {} 41 | 42 | public static async makeClient( 43 | client: MatrixClient, 44 | baseUrl: string 45 | ): Promise { 46 | const token = await getOpenIDToken(client); 47 | return new MjolnirWebAPIClient(token, baseUrl); 48 | } 49 | 50 | public async createMjolnir( 51 | roomToProtectId: string 52 | ): Promise { 53 | const body: { mxid: string; roomId: string } = await new Promise( 54 | (resolve, reject) => { 55 | request.post( 56 | `${this.baseURL}/create`, 57 | { 58 | json: { 59 | openId: this.openIDToken, 60 | roomId: roomToProtectId, 61 | }, 62 | }, 63 | (error, response) => { 64 | if (error === null || error === undefined) { 65 | resolve(response.body); 66 | } else if (error instanceof Error) { 67 | reject(error); 68 | } else { 69 | reject( 70 | new TypeError(`Someone is throwing things that aren't errors`) 71 | ); 72 | } 73 | } 74 | ); 75 | } 76 | ); 77 | return { 78 | mjolnirUserId: body.mxid, 79 | managementRoomId: body.roomId, 80 | }; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/commands/SetPowerLevelCommand.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { MatrixRoomID } from "@the-draupnir-project/matrix-basic-types"; 12 | import { 13 | MatrixRoomReferencePresentationSchema, 14 | MatrixUserIDPresentationType, 15 | StringPresentationType, 16 | describeCommand, 17 | tuple, 18 | } from "@the-draupnir-project/interface-manager"; 19 | import { Ok, Result, isError } from "@gnuxie/typescript-result"; 20 | import { Draupnir } from "../Draupnir"; 21 | import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites"; 22 | 23 | export const DraupnirSetPowerLevelCommand = describeCommand({ 24 | summary: 25 | "Set the power level of a user across the protected rooms set, or within the provided rooms", 26 | parameters: tuple( 27 | { 28 | name: "user", 29 | acceptor: MatrixUserIDPresentationType, 30 | }, 31 | { 32 | name: "power level", 33 | acceptor: StringPresentationType, 34 | } 35 | ), 36 | rest: { 37 | name: "rooms", 38 | acceptor: MatrixRoomReferencePresentationSchema, 39 | }, 40 | async executor( 41 | draupnir: Draupnir, 42 | _info, 43 | _keywords, 44 | roomRefs, 45 | user, 46 | rawPowerLevel 47 | ): Promise> { 48 | const powerLevel = Number.parseInt(rawPowerLevel, 10); 49 | const resolvedGivenRooms: MatrixRoomID[] = []; 50 | for (const room of roomRefs) { 51 | const resolvedResult = await draupnir.clientPlatform 52 | .toRoomResolver() 53 | .resolveRoom(room); 54 | if (isError(resolvedResult)) { 55 | return resolvedResult; 56 | } else { 57 | resolvedGivenRooms.push(resolvedResult.ok); 58 | } 59 | } 60 | const rooms = 61 | roomRefs.length === 0 62 | ? draupnir.protectedRoomsSet.allProtectedRooms 63 | : resolvedGivenRooms; 64 | for (const room of rooms) { 65 | await draupnir.client.setUserPowerLevel( 66 | user.toString(), 67 | room.toRoomIDOrAlias(), 68 | powerLevel 69 | ); 70 | } 71 | return Ok(undefined); 72 | }, 73 | }); 74 | 75 | DraupnirInterfaceAdaptor.describeRenderer(DraupnirSetPowerLevelCommand, { 76 | isAlwaysSupposedToUseDefaultRenderer: true, 77 | }); 78 | -------------------------------------------------------------------------------- /test/integration/utilsTest.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 - 2024 Gnuxie 2 | // Copyright 2021 - 2022 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { strict as assert } from "assert"; 12 | import { LogLevel } from "matrix-bot-sdk"; 13 | import { DraupnirTestContext, draupnirClient } from "./mjolnirSetupUtils"; 14 | import { 15 | NoticeMessageContent, 16 | RoomEvent, 17 | Value, 18 | } from "matrix-protection-suite"; 19 | 20 | describe("Test: utils", function () { 21 | it( 22 | "replaceRoomIdsWithPills correctly turns a room ID in to a pill", 23 | async function (this: DraupnirTestContext) { 24 | const managementRoomAlias = this.config.managementRoom; 25 | const draupnir = this.draupnir; 26 | const draupnirMatrixClient = draupnirClient(); 27 | if (draupnir === undefined || draupnirMatrixClient === null) { 28 | throw new TypeError(`Setup code is broken`); 29 | } 30 | const managementRoomOutput = draupnir.managementRoomOutput; 31 | await draupnir.client.sendStateEvent( 32 | draupnir.managementRoomID, 33 | "m.room.canonical_alias", 34 | "", 35 | { alias: managementRoomAlias } 36 | ); 37 | 38 | const message: RoomEvent = await new Promise((resolve) => { 39 | draupnirMatrixClient.on("room.message", (roomId, event) => { 40 | if (roomId === draupnir.managementRoomID) { 41 | if (event.content?.body?.startsWith("it's")) { 42 | resolve(event); 43 | } 44 | } 45 | }); 46 | void managementRoomOutput.logMessage( 47 | LogLevel.INFO, 48 | "replaceRoomIdsWithPills test", 49 | `it's fun here in ${draupnir.managementRoomID}`, 50 | [draupnir.managementRoomID, "!myfaketestid:example.com"] 51 | ); 52 | }); 53 | if (!Value.Check(NoticeMessageContent, message.content)) { 54 | throw new TypeError( 55 | `This test is written with the expectation logMessage will send a notice` 56 | ); 57 | } 58 | assert.equal( 59 | message.content.formatted_body, 60 | `it's fun here in ${managementRoomAlias}` 61 | ); 62 | } as unknown as Mocha.AsyncFunc 63 | ); 64 | }); 65 | -------------------------------------------------------------------------------- /src/safemode/ManagementRoom.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 - 2024 Gnuxie 2 | // Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { MatrixInterfaceCommandDispatcher } from "@the-draupnir-project/interface-manager"; 12 | import { 13 | MatrixRoomID, 14 | StringRoomID, 15 | } from "@the-draupnir-project/matrix-basic-types"; 16 | import { 17 | Logger, 18 | Ok, 19 | RoomEvent, 20 | RoomMessage, 21 | Task, 22 | TextMessageContent, 23 | Value, 24 | } from "matrix-protection-suite"; 25 | import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; 26 | import { MatrixEventContext } from "@the-draupnir-project/mps-interface-adaptor"; 27 | 28 | const log = new Logger("ManagementRoom"); 29 | 30 | export function makeCommandDispatcherTimelineListener( 31 | managementRoom: MatrixRoomID, 32 | client: MatrixSendClient, 33 | dispatcher: MatrixInterfaceCommandDispatcher 34 | ): (roomID: StringRoomID, event: RoomEvent) => void { 35 | const managementRoomID = managementRoom.toRoomIDOrAlias(); 36 | return function (roomID, event): void { 37 | if (roomID !== managementRoomID) { 38 | return; 39 | } 40 | if ( 41 | Value.Check(RoomMessage, event) && 42 | Value.Check(TextMessageContent, event.content) 43 | ) { 44 | if ( 45 | event.content.body === 46 | "** Unable to decrypt: The sender's device has not sent us the keys for this message. **" 47 | ) { 48 | log.info( 49 | `Unable to decrypt an event ${event.event_id} from ${event.sender} in the management room ${managementRoom.toPermalink()}.` 50 | ); 51 | void Task( 52 | client.unstableApis 53 | .addReactionToEvent(roomID, event.event_id, "⚠") 54 | .then((_) => Ok(undefined)) 55 | ); 56 | void Task( 57 | client.unstableApis 58 | .addReactionToEvent(roomID, event.event_id, "UISI") 59 | .then((_) => Ok(undefined)) 60 | ); 61 | void Task( 62 | client.unstableApis 63 | .addReactionToEvent(roomID, event.event_id, "🚨") 64 | .then((_) => Ok(undefined)) 65 | ); 66 | return; 67 | } 68 | dispatcher.handleCommandMessageEvent( 69 | { 70 | event, 71 | roomID, 72 | }, 73 | event.content.body 74 | ); 75 | } 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /src/queues/UnlistedUserRedactionQueue.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | import { LogLevel, LogService } from "matrix-bot-sdk"; 11 | import { RoomEvent } from "matrix-protection-suite"; 12 | import { Draupnir } from "../Draupnir"; 13 | import { 14 | StringUserID, 15 | StringRoomID, 16 | Permalinks, 17 | } from "@the-draupnir-project/matrix-basic-types"; 18 | 19 | /** 20 | * A queue of users who have been flagged for redaction typically by the flooding or image protection. 21 | * Specifically any new events sent by a queued user will be redacted. 22 | * This does not handle previously sent events, for that see the `EventRedactionQueue`. 23 | * These users are not listed as banned in any watch list and so may continue 24 | * to view a room until a moderator can investigate. 25 | */ 26 | export class UnlistedUserRedactionQueue { 27 | private usersToRedact = new Set(); 28 | 29 | public addUser(userID: StringUserID) { 30 | this.usersToRedact.add(userID); 31 | } 32 | 33 | public removeUser(userID: StringUserID) { 34 | this.usersToRedact.delete(userID); 35 | } 36 | 37 | public isUserQueued(userID: StringUserID): boolean { 38 | return this.usersToRedact.has(userID); 39 | } 40 | 41 | public async handleEvent( 42 | roomID: StringRoomID, 43 | event: RoomEvent, 44 | draupnir: Draupnir 45 | ) { 46 | if (this.isUserQueued(event["sender"])) { 47 | const permalink = Permalinks.forEvent(roomID, event["event_id"]); 48 | try { 49 | LogService.info( 50 | "AutomaticRedactionQueue", 51 | `Redacting event because the user is listed as bad: ${permalink}` 52 | ); 53 | if (!draupnir.config.noop) { 54 | await draupnir.client.redactEvent(roomID, event["event_id"]); 55 | } else { 56 | await draupnir.managementRoomOutput.logMessage( 57 | LogLevel.WARN, 58 | "AutomaticRedactionQueue", 59 | `Tried to redact ${permalink} but Draupnir is running in no-op mode` 60 | ); 61 | } 62 | } catch (e) { 63 | await draupnir.managementRoomOutput.logMessage( 64 | LogLevel.WARN, 65 | "AutomaticRedactionQueue", 66 | `Unable to redact message: ${permalink}` 67 | ); 68 | LogService.warn("AutomaticRedactionQueue", e); 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /test/unit/stores/roomAuditLogTest.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import Database from "better-sqlite3"; 6 | import { BetterSqliteOptions } from "../../../src/backingstore/better-sqlite3/BetterSqliteStore"; 7 | import { SqliteRoomAuditLog } from "../../../src/protections/RoomTakedown/SqliteRoomAuditLog"; 8 | import { 9 | describePolicyRule, 10 | LiteralPolicyRule, 11 | parsePolicyRule, 12 | PolicyRuleType, 13 | randomRoomID, 14 | Recommendation, 15 | } from "matrix-protection-suite"; 16 | import expect from "expect"; 17 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; 18 | 19 | describe("RoomAuditLog test", function () { 20 | const options = { path: ":memory:" } satisfies BetterSqliteOptions; 21 | const db = new Database(options.path); 22 | db.pragma("FOREIGN_KEYS = ON"); 23 | const store = new SqliteRoomAuditLog(options, db); 24 | it("Test that it doesn't return null or something", async function () { 25 | expect(store.isRoomTakendown(randomRoomID([]).toRoomIDOrAlias())).toBe( 26 | false 27 | ); 28 | }); 29 | it("Can logged takendown rooms", async function () { 30 | const bannedRoom = randomRoomID([]); 31 | const policyRoom = randomRoomID([]); 32 | const ban = parsePolicyRule( 33 | describePolicyRule({ 34 | room_id: policyRoom.toRoomIDOrAlias(), 35 | entity: bannedRoom.toRoomIDOrAlias(), 36 | reason: "spam", 37 | recommendation: Recommendation.Takedown, 38 | type: PolicyRuleType.Room, 39 | }) as never 40 | ).expect("Should bea ble to parse the policy rule."); 41 | ( 42 | await store.takedownRoom(ban as LiteralPolicyRule, { 43 | room_id: bannedRoom.toRoomIDOrAlias(), 44 | name: "Spam name", 45 | topic: "Spam topic", 46 | joined_members: 30, 47 | creator: "@spam:example.com" as StringUserID, 48 | }) 49 | ).expect("Should be able to takedown a room"); 50 | expect(store.isRoomTakendown(bannedRoom.toRoomIDOrAlias())).toBe(true); 51 | const takedownDetails = ( 52 | await store.getTakedownDetails(bannedRoom.toRoomIDOrAlias()) 53 | ).expect("Should be able to get takedown details"); 54 | expect(takedownDetails?.created_at).toBeDefined(); 55 | expect(takedownDetails?.creator).toBe("@spam:example.com"); 56 | expect(takedownDetails?.joined_members).toBe(30); 57 | expect(takedownDetails?.name).toBe("Spam name"); 58 | expect(takedownDetails?.topic).toBe("Spam topic"); 59 | expect(takedownDetails?.room_id).toBe(bannedRoom.toRoomIDOrAlias()); 60 | expect(takedownDetails?.policy_id).toBeDefined(); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /.github/workflows/mjolnir.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Gnuxie 2 | # 3 | # SPDX-License-Identifier: CC0-1.0 4 | 5 | name: Tests 6 | 7 | on: 8 | push: 9 | branches: [main] 10 | pull_request: 11 | branches: [main] 12 | schedule: 13 | - cron: "20 20 * * *" 14 | env: 15 | CARGO_TERM_COLOR: always 16 | 17 | jobs: 18 | build: 19 | name: Build & Lint 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Specifically use node 20 like in the readme. 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: "20" 28 | - run: corepack enable 29 | - run: corepack yarn install 30 | - run: corepack yarn build 31 | - run: corepack yarn lint 32 | unit: 33 | name: Unit tests 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Specifically use node 20 like in the readme. 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: "20" 41 | - run: corepack yarn install 42 | - run: corepack yarn test:unit 43 | integration: 44 | name: Integration tests 45 | runs-on: ubuntu-latest 46 | timeout-minutes: 60 47 | steps: 48 | - uses: actions/checkout@v4 49 | - uses: actions/setup-node@v4 50 | with: 51 | node-version: "20" 52 | - name: Fetch and build mx-tester (cached across runs) 53 | uses: baptiste0928/cargo-install@v3 54 | with: 55 | crate: mx-tester 56 | version: "0.3.3" 57 | - name: Setup image 58 | run: RUST_LOG=debug,hyper=info,rusttls=info mx-tester build up 59 | - name: Setup dependencies 60 | run: corepack yarn install 61 | - name: Run tests 62 | run: RUST_LOG=debug,hyper=info,rusttls=info mx-tester run 63 | - name: Cleanup 64 | run: mx-tester down 65 | appservice-integration: 66 | name: Application Service Integration tests 67 | runs-on: ubuntu-latest 68 | timeout-minutes: 30 69 | steps: 70 | - uses: actions/checkout@v4 71 | - uses: actions/setup-node@v4 72 | with: 73 | node-version: "20" 74 | - name: Fetch and build mx-tester (cached across runs) 75 | uses: baptiste0928/cargo-install@v3 76 | with: 77 | crate: mx-tester 78 | version: "0.3.3" 79 | - name: Setup image 80 | run: RUST_LOG=debug,hyper=info,rusttls=info mx-tester build up 81 | - name: Setup dependencies 82 | run: corepack yarn install 83 | - name: Run tests 84 | run: corepack yarn test:appservice:integration 85 | - name: Cleanup 86 | run: mx-tester down 87 | -------------------------------------------------------------------------------- /.github/workflows/sign-off.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Catalan Lover 2 | # Copyright 2022 - 2024 The Matrix.org Foundation C.I.C. 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 5 | # 6 | # SPDX-FileAttributionText: 7 | # This modified file incorporates work from matrix-org/backend-meta 8 | # https://github.com/matrix-org/backend-meta 9 | # 10 | 11 | name: Contribution requirements 12 | 13 | on: 14 | pull_request: 15 | types: [opened, edited, synchronize] 16 | workflow_call: 17 | 18 | jobs: 19 | signoff: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Check PR for sign-off text 23 | uses: actions/github-script@v7 24 | with: 25 | script: | 26 | // We don't require owners or members of the org to sign off. 27 | const authorAssociation = context.payload.pull_request.author_association; 28 | if (['OWNER', 'MEMBER'].includes(authorAssociation) || 29 | // GitHub sometimes mislables users as 'CONTRIBUTOR'/'COLLABORATOR', 30 | // so check that the user created the PR on the base project to check they have write access. 31 | context.payload.pull_request.head.user.login === context.repo.owner) { 32 | core.notice('Pull request does not require sign-off.'); 33 | return; 34 | } 35 | 36 | // This regex is intentionally left lenient. 37 | const signOffRegex = /signed[_\- ]off[_\- ]by: [\S ]+ ?/i; 38 | 39 | if (signOffRegex.test(context.payload.pull_request.body ?? "")) { 40 | core.notice('Pull request body contains a sign-off notice'); 41 | return; 42 | } 43 | 44 | const commits = await github.rest.pulls.listCommits({ 45 | pull_number: context.payload.pull_request.number, 46 | owner: context.repo.owner, 47 | repo: context.repo.repo, 48 | // It's *possible* the author has buried the sign-off 101 commits down, but 49 | // we don't want to max out the API searching for it. 50 | per_page: 100, 51 | }); 52 | 53 | const commit = commits.data.find(c => signOffRegex.test(c.commit.message)); 54 | if (commit) { 55 | core.notice(`Commit '${commit.id}' contains a sign-off notice`); 56 | return; 57 | } 58 | 59 | core.setFailed('No sign off found. Please ensure you have signed off following the advice in https://the-draupnir-project.github.io/draupnir-documentation/contributing#sign-off .') 60 | core.notice('Ensure you have matched the format `Signed-off-by: Your Name `') 61 | -------------------------------------------------------------------------------- /src/webapis/SynapseHTTPAntispam/CheckEventForSpamEndpoint.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { Type } from "@sinclair/typebox"; 6 | import { 7 | EDStatic, 8 | isError, 9 | Logger, 10 | RoomEvent, 11 | Task, 12 | Value, 13 | } from "matrix-protection-suite"; 14 | import { SpamCheckEndpointPluginManager } from "./SpamCheckEndpointPluginManager"; 15 | import { Request, Response } from "express"; 16 | 17 | const log = new Logger("CheckEventForSpamEndpoint"); 18 | 19 | export type CheckEventForSpamListenerArguments = Parameters< 20 | (details: CheckEventForSpamRequestBody) => void 21 | >; 22 | 23 | export type CheckEventForSpamRequestBody = EDStatic< 24 | typeof CheckEventForSpamRequestBody 25 | >; 26 | export const CheckEventForSpamRequestBody = Type.Object({ 27 | event: RoomEvent(Type.Unknown()), 28 | }); 29 | 30 | export class CheckEventForSpamEndpoint { 31 | public constructor( 32 | private readonly pluginManager: SpamCheckEndpointPluginManager 33 | ) { 34 | // nothing to do. 35 | } 36 | 37 | private async handleCheckEventForSpamAsync( 38 | request: Request, 39 | response: Response, 40 | isResponded: boolean 41 | ): Promise { 42 | const decodedBody = Value.Decode( 43 | CheckEventForSpamRequestBody, 44 | request.body 45 | ); 46 | if (isError(decodedBody)) { 47 | log.error("Error decoding request body:", decodedBody.error); 48 | if (!isResponded && this.pluginManager.isBlocking()) { 49 | response 50 | .status(400) 51 | .send({ errcode: "M_INVALID_PARAM", error: "Error handling event" }); 52 | } 53 | return; 54 | } 55 | if (!isResponded && this.pluginManager.isBlocking()) { 56 | const blockingResult = await this.pluginManager.callBlockingHandles( 57 | decodedBody.ok 58 | ); 59 | if (blockingResult === "NOT_SPAM") { 60 | response.status(200); 61 | response.send({}); 62 | } else { 63 | response.status(400); 64 | response.send(blockingResult); 65 | } 66 | } else if (!isResponded) { 67 | response.status(200); 68 | response.send({}); 69 | } 70 | this.pluginManager.callNonBlockingHandlesInTask(decodedBody.ok); 71 | } 72 | 73 | public handleCheckEventForSpam(request: Request, response: Response): void { 74 | if (!this.pluginManager.isBlocking()) { 75 | response.status(200); 76 | response.send({}); 77 | } 78 | void Task( 79 | this.handleCheckEventForSpamAsync( 80 | request, 81 | response, 82 | !this.pluginManager.isBlocking() 83 | ) 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /test/unit/protections/RedactionSynchronisationTest.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { 6 | describeProtectedRoomsSet, 7 | Membership, 8 | Ok, 9 | PolicyRuleType, 10 | randomRoomID, 11 | randomUserID, 12 | Recommendation, 13 | } from "matrix-protection-suite"; 14 | import { 15 | RedactionSynchronisationConsequences, 16 | StandardRedactionSynchronisation, 17 | } from "../../../src/protections/RedactionSynchronisation"; 18 | import { MatrixGlob } from "@the-draupnir-project/matrix-basic-types"; 19 | import { createMock } from "ts-auto-mock"; 20 | import expect from "expect"; 21 | 22 | describe("RedactionSynchronisation", function () { 23 | it("Attempts to retract invitations on permission requirements met", async function () { 24 | const room = randomRoomID([]); 25 | const targetUser = randomUserID(); 26 | const { protectedRoomsSet } = await describeProtectedRoomsSet({ 27 | rooms: [ 28 | { 29 | room, 30 | membershipDescriptions: [ 31 | { 32 | sender: targetUser, 33 | membership: Membership.Leave, 34 | }, 35 | { 36 | sender: targetUser, 37 | membership: Membership.Invite, 38 | target: randomUserID(), 39 | }, 40 | ], 41 | }, 42 | ], 43 | lists: [ 44 | { 45 | policyDescriptions: [ 46 | { 47 | recommendation: Recommendation.Ban, 48 | entity: targetUser, 49 | reason: "spam", 50 | type: PolicyRuleType.User, 51 | }, 52 | ], 53 | }, 54 | ], 55 | }); 56 | let mockMethodCalls = 0; 57 | const mockConsequences = createMock({ 58 | async redactMessagesIn(userIDOrGlob, _reason, _roomIDs) { 59 | expect(userIDOrGlob).toBe(targetUser); 60 | mockMethodCalls += 1; 61 | return Ok(undefined); 62 | }, 63 | async rejectInvite(roomID, sender, _reciever, _reason) { 64 | expect(roomID).toBe(room.toRoomIDOrAlias()); 65 | expect(sender).toBe(targetUser); 66 | mockMethodCalls += 1; 67 | return Ok(undefined); 68 | }, 69 | }); 70 | const redactionSynronisationService = new StandardRedactionSynchronisation( 71 | [new MatrixGlob("spam")], 72 | mockConsequences, 73 | protectedRoomsSet.watchedPolicyRooms, 74 | protectedRoomsSet.setRoomMembership, 75 | protectedRoomsSet.setPoliciesMatchingMembership 76 | ); 77 | redactionSynronisationService.handlePermissionRequirementsMet( 78 | room.toRoomIDOrAlias() 79 | ); 80 | expect(mockMethodCalls).toBe(1); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/webapis/SynapseHTTPAntispam/UserMayJoinRoomEndpoint.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { Type } from "@sinclair/typebox"; 6 | import { 7 | EDStatic, 8 | isError, 9 | Logger, 10 | StringRoomIDSchema, 11 | StringUserIDSchema, 12 | Task, 13 | Value, 14 | } from "matrix-protection-suite"; 15 | import { SpamCheckEndpointPluginManager } from "./SpamCheckEndpointPluginManager"; 16 | import { Request, Response } from "express"; 17 | 18 | const log = new Logger("UserMayJoinRoomEndpoint"); 19 | 20 | export type UserMayJoinRoomListenerArguments = Parameters< 21 | (details: UserMayJoinRoomRequestBody) => void 22 | >; 23 | 24 | export type UserMayJoinRoomRequestBody = EDStatic< 25 | typeof UserMayJoinRoomRequestBody 26 | >; 27 | export const UserMayJoinRoomRequestBody = Type.Object({ 28 | user: StringUserIDSchema, 29 | room: StringRoomIDSchema, 30 | is_invited: Type.Boolean(), 31 | }); 32 | 33 | export class UserMayJoinRoomEndpoint { 34 | public constructor( 35 | private readonly pluginManager: SpamCheckEndpointPluginManager 36 | ) { 37 | // nothing to do. 38 | } 39 | 40 | private async handleUserMayJoinRoomAsync( 41 | request: Request, 42 | response: Response, 43 | isResponded: boolean 44 | ): Promise { 45 | const decodedBody = Value.Decode(UserMayJoinRoomRequestBody, request.body); 46 | if (isError(decodedBody)) { 47 | log.error("Error decoding request body:", decodedBody.error); 48 | if (!isResponded && this.pluginManager.isBlocking()) { 49 | response.status(400).send({ 50 | errcode: "M_INVALID_PARAM", 51 | error: "Error handling user, room, and is_invited", 52 | }); 53 | } 54 | return; 55 | } 56 | if (!isResponded && this.pluginManager.isBlocking()) { 57 | const blockingResult = await this.pluginManager.callBlockingHandles( 58 | decodedBody.ok 59 | ); 60 | if (blockingResult === "NOT_SPAM") { 61 | response.status(200); 62 | response.send({}); 63 | } else { 64 | response.status(400); 65 | response.send(blockingResult); 66 | } 67 | } else if (!isResponded) { 68 | response.status(200); 69 | response.send({}); 70 | } 71 | this.pluginManager.callNonBlockingHandlesInTask(decodedBody.ok); 72 | } 73 | 74 | public handleUserMayJoinRoom(request: Request, response: Response): void { 75 | if (!this.pluginManager.isBlocking()) { 76 | response.status(200); 77 | response.send({}); 78 | } 79 | void Task( 80 | this.handleUserMayJoinRoomAsync( 81 | request, 82 | response, 83 | !this.pluginManager.isBlocking() 84 | ) 85 | ); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/commands/unban/UnbanEntity.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | // So the purpose of this is just to remove all policies related to an entity. 6 | // Prompt which policies will be removed, and then remove them if it's accepted. 7 | // For finer control, they will need to use policy remove command. 8 | 9 | import { 10 | isStringRoomID, 11 | MatrixRoomID, 12 | StringRoomID, 13 | } from "@the-draupnir-project/matrix-basic-types"; 14 | import { 15 | PolicyRoomManager, 16 | PolicyRuleType, 17 | Recommendation, 18 | RoomSetResultBuilder, 19 | WatchedPolicyRooms, 20 | } from "matrix-protection-suite"; 21 | import { ListMatches } from "../Rules"; 22 | import { isError, Ok, Result } from "@gnuxie/typescript-result"; 23 | import { UnbanEntityPreview, UnbanEntityResult } from "./Unban"; 24 | 25 | export function findPoliciesToRemove( 26 | entity: MatrixRoomID | string, 27 | watchedPolicyRooms: WatchedPolicyRooms 28 | ): UnbanEntityPreview { 29 | const entityType = 30 | entity instanceof MatrixRoomID 31 | ? PolicyRuleType.Room 32 | : PolicyRuleType.Server; 33 | const matches: ListMatches[] = []; 34 | for (const profile of watchedPolicyRooms.allRooms) { 35 | matches.push({ 36 | room: profile.room, 37 | roomID: profile.room.toRoomIDOrAlias(), 38 | profile, 39 | matches: [Recommendation.Ban, Recommendation.Takedown].flatMap( 40 | (recommendation) => 41 | profile.revision.allRulesMatchingEntity(entity.toString(), { 42 | type: entityType, 43 | searchHashedRules: true, 44 | recommendation, 45 | }) 46 | ), 47 | }); 48 | } 49 | return { 50 | entity, 51 | policyMatchesToRemove: matches, 52 | }; 53 | } 54 | 55 | export async function unbanEntity( 56 | entity: StringRoomID | string, 57 | policyRoomManager: PolicyRoomManager, 58 | policyMatches: UnbanEntityPreview 59 | ): Promise> { 60 | const entityType = isStringRoomID(entity) 61 | ? PolicyRuleType.Room 62 | : PolicyRuleType.Server; 63 | const policiesRemoved = new RoomSetResultBuilder(); 64 | for (const matches of policyMatches.policyMatchesToRemove) { 65 | const editor = await policyRoomManager.getPolicyRoomEditor(matches.room); 66 | if (isError(editor)) { 67 | policiesRemoved.addResult( 68 | matches.roomID, 69 | editor.elaborate("Unable to obtain the policy room editor") 70 | ); 71 | } else { 72 | policiesRemoved.addResult( 73 | matches.roomID, 74 | (await editor.ok.unbanEntity(entityType, entity)) as Result 75 | ); 76 | } 77 | } 78 | return Ok({ 79 | ...policyMatches, 80 | policyRemovalResult: policiesRemoved.getResult(), 81 | }); 82 | } 83 | -------------------------------------------------------------------------------- /src/protections/ConfigMigration/CapabilitySetProviderMigration.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | import { Ok, ResultError } from "@gnuxie/typescript-result"; 6 | import { 7 | CapabilityProviderConfig, 8 | DRAUPNIR_SCHEMA_VERSION_KEY, 9 | Logger, 10 | SchemedDataManager, 11 | ServerACLSynchronisationCapability, 12 | SimulatedServerBanSynchronisationCapability, 13 | Value, 14 | } from "matrix-protection-suite"; 15 | 16 | const log = new Logger("CapabilitySetProviderMigration"); 17 | 18 | export async function serverBanSynchronisationCapabilityRename( 19 | input: CapabilityProviderConfig, 20 | toVersion: number 21 | ) { 22 | // Annoyingly, having a record type on the top level mixed with known 23 | // properties is really terrible for typescript to deal with. 24 | if (!Value.Check(CapabilityProviderConfig, input)) { 25 | return ResultError.Result( 26 | `The data for the capability provider config is corrupted.` 27 | ); 28 | } 29 | const protectionCapabilityName = "serverConsequences"; 30 | const oldSimulatedServerConsequencesName = "SimulatedServerConsequences"; 31 | const oldServerACLConsequencesName = "ServerACLConsequences"; 32 | const oldServerConsequencesSet = input[protectionCapabilityName]; 33 | if (oldServerConsequencesSet === undefined) { 34 | return Ok({ 35 | ...input, 36 | [DRAUPNIR_SCHEMA_VERSION_KEY]: toVersion, 37 | } as unknown as CapabilityProviderConfig); 38 | } 39 | log.debug( 40 | `Migrating capability provider from ${protectionCapabilityName} to ServerBanSynchronisationCapability` 41 | ); 42 | const makeProviderSet = ( 43 | capabilityName: string 44 | ): CapabilityProviderConfig => { 45 | return { 46 | [protectionCapabilityName]: { 47 | capability_provider_name: capabilityName, 48 | }, 49 | [DRAUPNIR_SCHEMA_VERSION_KEY]: toVersion, 50 | } as unknown as CapabilityProviderConfig; 51 | }; 52 | switch (oldServerConsequencesSet.capability_provider_name) { 53 | case oldSimulatedServerConsequencesName: 54 | return Ok( 55 | makeProviderSet(SimulatedServerBanSynchronisationCapability.name) 56 | ); 57 | case oldServerACLConsequencesName: 58 | return Ok(makeProviderSet(ServerACLSynchronisationCapability.name)); 59 | default: 60 | // if someone has written their own custom thing, they probably need to know that we've 61 | // change the interface name. 62 | throw new TypeError( 63 | `Unknown capability provider name: ${oldServerConsequencesSet.capability_provider_name}` 64 | ); 65 | } 66 | } 67 | 68 | export const CapabilitySetProviderMigration = 69 | new SchemedDataManager([ 70 | serverBanSynchronisationCapabilityRename, 71 | ]); 72 | -------------------------------------------------------------------------------- /src/appservice/bot/AppserviceBotCommandDispatcher.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | // 5 | // SPDX-FileAttributionText: 6 | // This modified file incorporates work from Draupnir 7 | // https://github.com/the-draupnir-project/Draupnir 8 | // 9 | 10 | import { 11 | BasicInvocationInformation, 12 | CommandNormaliser, 13 | JSInterfaceCommandDispatcher, 14 | makeCommandNormaliser, 15 | MatrixInterfaceCommandDispatcher, 16 | StandardJSInterfaceCommandDispatcher, 17 | StandardMatrixInterfaceCommandDispatcher, 18 | } from "@the-draupnir-project/interface-manager"; 19 | import { AppserviceAdaptorContext } from "./AppserviceBotPrerequisite"; 20 | import { AppserviceBotCommands } from "./AppserviceBotCommandTable"; 21 | import { AppserviceBotHelpCommand } from "./AppserviceBotHelp"; 22 | import { 23 | AppserviceBotInterfaceAdaptor, 24 | AppserviceAdaptorContextToCommandContextTranslator, 25 | } from "./AppserviceBotInterfaceAdaptor"; 26 | import { 27 | invocationInformationFromMatrixEventcontext, 28 | MatrixEventContext, 29 | MPSCommandDispatcherCallbacks, 30 | } from "@the-draupnir-project/mps-interface-adaptor"; 31 | 32 | function makeAppserviceCommandNormaliser( 33 | appserviceContext: AppserviceAdaptorContext 34 | ): CommandNormaliser { 35 | return makeCommandNormaliser(appserviceContext.clientUserID, { 36 | symbolPrefixes: ["!"], 37 | isAllowedOnlySymbolPrefixes: false, 38 | additionalPrefixes: ["admin"], 39 | getDisplayName: function (): string { 40 | // TODO: I don't nkow how we're going to do this yet but we'll 41 | // figure it out one day. 42 | return "admin"; 43 | }, 44 | normalisedPrefix: "admin", 45 | }); 46 | } 47 | 48 | export function makeAppserviceBotCommandDispatcher( 49 | appserviceContext: AppserviceAdaptorContext 50 | ): MatrixInterfaceCommandDispatcher { 51 | return new StandardMatrixInterfaceCommandDispatcher( 52 | AppserviceBotInterfaceAdaptor, 53 | appserviceContext, 54 | AppserviceBotCommands, 55 | AppserviceBotHelpCommand, 56 | invocationInformationFromMatrixEventcontext, 57 | { 58 | ...MPSCommandDispatcherCallbacks, 59 | commandNormaliser: makeAppserviceCommandNormaliser(appserviceContext), 60 | } 61 | ); 62 | } 63 | 64 | export function makeAppserviceJSCommandDispatcher( 65 | appserviceContext: AppserviceAdaptorContext 66 | ): JSInterfaceCommandDispatcher { 67 | return new StandardJSInterfaceCommandDispatcher( 68 | AppserviceBotCommands, 69 | AppserviceBotHelpCommand, 70 | appserviceContext, 71 | { 72 | ...MPSCommandDispatcherCallbacks, 73 | commandNormaliser: makeAppserviceCommandNormaliser(appserviceContext), 74 | }, 75 | AppserviceAdaptorContextToCommandContextTranslator 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /src/appservice/bot/ListCommand.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { 6 | ActionError, 7 | ActionResult, 8 | isError, 9 | Ok, 10 | } from "matrix-protection-suite"; 11 | import { UnstartedDraupnir } from "../../draupnirfactory/StandardDraupnirManager"; 12 | import { AppserviceAdaptorContext } from "./AppserviceBotPrerequisite"; 13 | import { 14 | DeadDocumentJSX, 15 | describeCommand, 16 | MatrixUserIDPresentationType, 17 | tuple, 18 | } from "@the-draupnir-project/interface-manager"; 19 | import { AppserviceBotInterfaceAdaptor } from "./AppserviceBotInterfaceAdaptor"; 20 | 21 | export const AppserviceListUnstartedCommand = describeCommand({ 22 | summary: "List any Draupnir that failed to start.", 23 | async executor( 24 | context: AppserviceAdaptorContext 25 | ): Promise> { 26 | return Ok(context.appservice.draupnirManager.getUnstartedDraupnirs()); 27 | }, 28 | parameters: [], 29 | }); 30 | 31 | AppserviceBotInterfaceAdaptor.describeRenderer(AppserviceListUnstartedCommand, { 32 | JSXRenderer: function (result) { 33 | if (isError(result)) { 34 | return Ok(undefined); 35 | } 36 | const draupnirs = result.ok; 37 | return Ok( 38 | 39 | Unstarted Draupnir: {draupnirs.length} 40 |
    41 | {draupnirs.map((draupnir) => { 42 | return ( 43 |
  • 44 | {draupnir.clientUserID} 45 | {draupnir.failType}: 46 |
    47 | {String(draupnir.cause)} 48 |
  • 49 | ); 50 | })} 51 |
52 |
53 | ); 54 | }, 55 | }); 56 | 57 | export const AppserviceRestartDraupnirCommand = describeCommand({ 58 | summary: "Restart a Draupnir.", 59 | parameters: tuple({ 60 | name: "draupnir", 61 | acceptor: MatrixUserIDPresentationType, 62 | description: "The userid of the draupnir to restart", 63 | }), 64 | async executor( 65 | context: AppserviceAdaptorContext, 66 | _info, 67 | _keywords, 68 | _rest, 69 | draupnirUser 70 | ): Promise> { 71 | const draupnirManager = context.appservice.draupnirManager; 72 | const draupnir = draupnirManager.findUnstartedDraupnir( 73 | draupnirUser.toString() 74 | ); 75 | if (draupnir !== undefined) { 76 | return ActionError.Result( 77 | `We can't find the unstarted draupnir ${draupnirUser.toString()}, is it already running?` 78 | ); 79 | } 80 | return await draupnirManager.startDraupnirFromMXID(draupnirUser.toString()); 81 | }, 82 | }); 83 | 84 | AppserviceBotInterfaceAdaptor.describeRenderer( 85 | AppserviceRestartDraupnirCommand, 86 | { 87 | isAlwaysSupposedToUseDefaultRenderer: true, 88 | } 89 | ); 90 | -------------------------------------------------------------------------------- /test/integration/httpAntispamTest.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import expect from "expect"; 6 | import { DraupnirTestContext } from "./mjolnirSetupUtils"; 7 | import { 8 | ActionException, 9 | isOk, 10 | MatrixException, 11 | } from "matrix-protection-suite"; 12 | import { MatrixError } from "matrix-bot-sdk"; 13 | 14 | describe("Test for http antispam callbacks", function () { 15 | it("We can process check_event_for_spam", async function ( 16 | this: DraupnirTestContext 17 | ) { 18 | const draupnir = this.draupnir; 19 | if (draupnir === undefined) { 20 | throw new TypeError(`setup code is wrong`); 21 | } 22 | const synapseHTTPAntispam = draupnir.synapseHTTPAntispam; 23 | if (synapseHTTPAntispam === undefined) { 24 | throw new TypeError("Setup code is wrong"); 25 | } 26 | const promise = new Promise((resolve) => { 27 | synapseHTTPAntispam.checkEventForSpamHandles.registerNonBlockingHandle( 28 | (details) => { 29 | if (details.event.sender === draupnir.clientUserID) { 30 | resolve(undefined); 31 | } 32 | } 33 | ); 34 | }); 35 | ( 36 | await draupnir.clientPlatform 37 | .toRoomMessageSender() 38 | .sendMessage(draupnir.managementRoomID, { 39 | body: "hello", 40 | msgtype: "m.text", 41 | }) 42 | ).expect("should be able to send the message just fine"); 43 | await promise; 44 | // now try blocking 45 | synapseHTTPAntispam.checkEventForSpamHandles.registerBlockingHandle(() => { 46 | return Promise.resolve({ errcode: "M_FORBIDDEN", error: "no." }); 47 | }); 48 | const sendResult = await draupnir.clientPlatform 49 | .toRoomMessageSender() 50 | .sendMessage(draupnir.managementRoomID, { 51 | body: "hello", 52 | msgtype: "m.text", 53 | }); 54 | if (isOk(sendResult)) { 55 | throw new TypeError("We expect the result to be blocked"); 56 | } 57 | if (!(sendResult.error instanceof ActionException)) { 58 | throw new TypeError( 59 | "We're trying to destructure this to get the MatrixError" 60 | ); 61 | } 62 | // I'm pretty sure there are different versions of this being used in the code base 63 | // so instanceof fails :/ sucks balls mare 64 | // https://github.com/the-draupnir-project/Draupnir/issues/760 65 | // https://github.com/the-draupnir-project/Draupnir/issues/759 66 | if (sendResult.error instanceof MatrixException) { 67 | expect(sendResult.error.matrixErrorMessage).toBe("no."); 68 | expect(sendResult.error.matrixErrorCode).toBe("M_FORBIDDEN"); 69 | } else { 70 | const matrixError = sendResult.error.exception as MatrixError; 71 | expect(matrixError.error).toBe("no."); 72 | expect(matrixError.errcode).toBe("M_FORBIDDEN"); 73 | } 74 | } as unknown as Mocha.AsyncFunc); 75 | }); 76 | -------------------------------------------------------------------------------- /src/webapis/SynapseHTTPAntispam/SpamCheckEndpointPluginManager.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { Logger, Task } from "matrix-protection-suite"; 6 | 7 | type BlockingResponse = 8 | | "NOT_SPAM" 9 | | { 10 | errcode: string; 11 | error: string; 12 | }; 13 | 14 | const log = new Logger("SpamCheckEndpointPluginManager"); 15 | 16 | export type BlockingCallback = ( 17 | ...args: CBArguments 18 | ) => Promise; 19 | export type NonBlockingCallback = ( 20 | ...args: CBArguments 21 | ) => void; 22 | 23 | export class SpamCheckEndpointPluginManager { 24 | private readonly blockingHandles = new Set>(); 25 | private readonly nonBlockingHandles = new Set< 26 | NonBlockingCallback 27 | >(); 28 | 29 | public registerBlockingHandle(handle: BlockingCallback): void { 30 | this.blockingHandles.add(handle); 31 | } 32 | 33 | public registerNonBlockingHandle( 34 | handle: NonBlockingCallback 35 | ): void { 36 | this.nonBlockingHandles.add(handle); 37 | } 38 | 39 | public unregisterHandle( 40 | handle: BlockingCallback | NonBlockingCallback 41 | ): void { 42 | this.blockingHandles.delete(handle as BlockingCallback); 43 | this.nonBlockingHandles.delete(handle as NonBlockingCallback); 44 | } 45 | 46 | public unregisterListeners(): void { 47 | this.blockingHandles.clear(); 48 | this.nonBlockingHandles.clear(); 49 | } 50 | 51 | public isBlocking(): boolean { 52 | return this.blockingHandles.size > 0; 53 | } 54 | 55 | public async callBlockingHandles( 56 | ...args: CBArguments 57 | ): ReturnType> { 58 | const results = await Promise.allSettled( 59 | [...this.blockingHandles.values()].map((handle) => handle(...args)) 60 | ); 61 | for (const result of results) { 62 | if (result.status === "rejected") { 63 | log.error( 64 | "Error processing a blocking spam check callback:", 65 | result.reason 66 | ); 67 | } else { 68 | if (result.value !== "NOT_SPAM") { 69 | return result.value; 70 | } 71 | } 72 | } 73 | return "NOT_SPAM"; 74 | } 75 | 76 | public callNonBlockingHandles(...args: CBArguments): void { 77 | for (const handle of this.nonBlockingHandles) { 78 | try { 79 | handle(...args); 80 | } catch (e) { 81 | log.error("Error processing a non blocking spam check callback:", e); 82 | } 83 | } 84 | } 85 | 86 | public callNonBlockingHandlesInTask(...args: CBArguments): void { 87 | void Task( 88 | (async () => { 89 | this.callNonBlockingHandles(...args); 90 | })() 91 | ); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/commands/server-admin/ShutdownRoomCommand.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // Copyright 2020 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { Result, isError } from "@gnuxie/typescript-result"; 12 | import { 13 | MatrixRoomReferencePresentationSchema, 14 | StringPresentationType, 15 | describeCommand, 16 | tuple, 17 | } from "@the-draupnir-project/interface-manager"; 18 | import { Draupnir } from "../../Draupnir"; 19 | import { ActionError } from "matrix-protection-suite"; 20 | import { DraupnirInterfaceAdaptor } from "../DraupnirCommandPrerequisites"; 21 | 22 | export const SynapseAdminShutdownRoomCommand = describeCommand({ 23 | summary: 24 | "Prevents access to the the room on this server and sends a message to all users that they have violated the terms of service.", 25 | parameters: tuple({ 26 | name: "room", 27 | acceptor: MatrixRoomReferencePresentationSchema, 28 | }), 29 | rest: { 30 | name: "reason", 31 | acceptor: StringPresentationType, 32 | }, 33 | keywords: { 34 | keywordDescriptions: { 35 | notify: { 36 | isFlag: true, 37 | description: 38 | "Whether to send the content violation notification message. This is an invitation that will be sent to the room's joined members which explains the reason for the shutdown.", 39 | }, 40 | }, 41 | }, 42 | async executor( 43 | draupnir: Draupnir, 44 | _info, 45 | keywords, 46 | reasonParts, 47 | targetRoom 48 | ): Promise> { 49 | const notify = keywords.getKeywordValue("notify", false); 50 | const isAdmin = await draupnir.synapseAdminClient?.isSynapseAdmin(); 51 | if (isAdmin === undefined || isError(isAdmin) || !isAdmin.ok) { 52 | return ActionError.Result( 53 | "I am not a Synapse administrator, or the endpoint to shutdown a room is blocked" 54 | ); 55 | } 56 | if (draupnir.synapseAdminClient === undefined) { 57 | throw new TypeError(`Should be impossible at this point.`); 58 | } 59 | const resolvedRoom = await draupnir.clientPlatform 60 | .toRoomResolver() 61 | .resolveRoom(targetRoom); 62 | if (isError(resolvedRoom)) { 63 | return resolvedRoom; 64 | } 65 | const reason = reasonParts.join(" "); 66 | return await draupnir.synapseAdminClient.shutdownRoomV2( 67 | resolvedRoom.ok.toRoomIDOrAlias(), 68 | { 69 | ...(notify 70 | ? { message: reason, new_room_user_id: draupnir.clientUserID } 71 | : {}), 72 | block: true, 73 | purge: true, 74 | } 75 | ); 76 | }, 77 | }); 78 | 79 | DraupnirInterfaceAdaptor.describeRenderer(SynapseAdminShutdownRoomCommand, { 80 | isAlwaysSupposedToUseDefaultRenderer: true, 81 | }); 82 | -------------------------------------------------------------------------------- /src/webapis/SynapseHTTPAntispam/UserMayInviteEndpoint.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { SpamCheckEndpointPluginManager } from "./SpamCheckEndpointPluginManager"; 6 | import { Request, Response } from "express"; 7 | import { 8 | EDStatic, 9 | isError, 10 | Logger, 11 | StringRoomIDSchema, 12 | StringUserIDSchema, 13 | Task, 14 | Value, 15 | } from "matrix-protection-suite"; 16 | import { Type } from "@sinclair/typebox"; 17 | 18 | // for check_event_for_spam we will leave the event as unparsed 19 | 20 | const log = new Logger("UserMayInviteEndpoint"); 21 | 22 | export type UserMayInviteListenerArguments = Parameters< 23 | (details: UserMayInviteRequestBody) => void 24 | >; 25 | 26 | export type UserMayInviteRequestBody = EDStatic< 27 | typeof UserMayInviteRequestBody 28 | >; 29 | export const UserMayInviteRequestBody = Type.Object({ 30 | inviter: StringUserIDSchema, 31 | invitee: StringUserIDSchema, 32 | room_id: StringRoomIDSchema, 33 | }); 34 | 35 | export type UserMayInvitePluginManager = 36 | SpamCheckEndpointPluginManager; 37 | export class UserMayInviteEndpoint { 38 | public constructor( 39 | private readonly pluginManager: SpamCheckEndpointPluginManager 40 | ) { 41 | // nothing to do. 42 | } 43 | 44 | private async handleUserMayInviteAsync( 45 | request: Request, 46 | response: Response, 47 | isResponded: boolean 48 | ): Promise { 49 | const decodedBody = Value.Decode(UserMayInviteRequestBody, request.body); 50 | if (isError(decodedBody)) { 51 | log.error("Error decoding request body:", decodedBody.error); 52 | if (!isResponded && this.pluginManager.isBlocking()) { 53 | response.status(400).send({ 54 | errcode: "M_INVALID_PARAM", 55 | error: "Error handling inviter, invitee, and room_id", 56 | }); 57 | } 58 | return; 59 | } 60 | if (!isResponded && this.pluginManager.isBlocking()) { 61 | const blockingResult = await this.pluginManager.callBlockingHandles( 62 | decodedBody.ok 63 | ); 64 | if (blockingResult === "NOT_SPAM") { 65 | response.status(200); 66 | response.send({}); 67 | } else { 68 | response.status(400); 69 | response.send(blockingResult); 70 | } 71 | } else if (!isResponded) { 72 | response.status(200); 73 | response.send({}); 74 | } 75 | this.pluginManager.callNonBlockingHandlesInTask(decodedBody.ok); 76 | } 77 | 78 | public handleUserMayInvite(request: Request, response: Response): void { 79 | if (!this.pluginManager.isBlocking()) { 80 | response.status(200); 81 | response.send({}); 82 | } 83 | void Task( 84 | this.handleUserMayInviteAsync( 85 | request, 86 | response, 87 | !this.pluginManager.isBlocking() 88 | ) 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /test/integration/commands/hijackRoomCommandTest.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 - 2024 Gnuxie 2 | // Copyright 2021, 2022 Marco Cirillo 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { strict as assert } from "assert"; 12 | import { newTestUser } from "../clientHelper"; 13 | import { getFirstReaction } from "./commandUtils"; 14 | import { DraupnirTestContext, draupnirSafeEmitter } from "../mjolnirSetupUtils"; 15 | 16 | // Breaks with this test. 17 | describe("Test: The make admin command", function () { 18 | it("Draupnir make the bot self room administrator", async function ( 19 | this: DraupnirTestContext 20 | ) { 21 | this.timeout(90000); 22 | if (!this.config.admin?.enableMakeRoomAdminCommand) { 23 | this.done(); 24 | } 25 | const draupnir = this.draupnir; 26 | if (draupnir === undefined) { 27 | throw new TypeError(`Test didn't setup correctly`); 28 | } 29 | const moderator = await newTestUser(this.config.homeserverUrl, { 30 | name: { contains: "moderator" }, 31 | }); 32 | const userA = await newTestUser(this.config.homeserverUrl, { 33 | name: { contains: "a" }, 34 | }); 35 | const userAId = await userA.getUserId(); 36 | 37 | await moderator.joinRoom(draupnir.managementRoomID); 38 | const targetRoom = await moderator.createRoom({ 39 | invite: [draupnir.clientUserID], 40 | preset: "public_chat", 41 | }); 42 | await moderator.sendMessage(draupnir.managementRoomID, { 43 | msgtype: "m.text.", 44 | body: `!draupnir rooms add ${targetRoom}`, 45 | }); 46 | await userA.joinRoom(targetRoom); 47 | const powerLevelsBefore = await moderator.getRoomStateEvent( 48 | targetRoom, 49 | "m.room.power_levels", 50 | "" 51 | ); 52 | assert.notEqual( 53 | powerLevelsBefore["users"][draupnir.clientUserID], 54 | 100, 55 | `Bot should not yet be an admin of ${targetRoom}` 56 | ); 57 | await getFirstReaction( 58 | draupnirSafeEmitter(), 59 | draupnir.managementRoomID, 60 | "✅", 61 | async () => { 62 | return await moderator.sendMessage(draupnir.managementRoomID, { 63 | msgtype: "m.text", 64 | body: `!draupnir hijack room ${targetRoom} ${draupnir.clientUserID}`, 65 | }); 66 | } 67 | ); 68 | 69 | const powerLevelsAfter = await moderator.getRoomStateEvent( 70 | targetRoom, 71 | "m.room.power_levels", 72 | "" 73 | ); 74 | assert.equal( 75 | powerLevelsAfter["users"][draupnir.clientUserID], 76 | 100, 77 | "Bot should be a room admin." 78 | ); 79 | assert.equal( 80 | powerLevelsAfter["users"][userAId], 81 | undefined, 82 | "User A is not supposed to be a room admin." 83 | ); 84 | } as unknown as Mocha.AsyncFunc); 85 | }); 86 | -------------------------------------------------------------------------------- /src/commands/DraupnirCommandDispatcher.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | // 5 | // SPDX-FileAttributionText: 6 | // This modified file incorporates work from Draupnir 7 | // https://github.com/the-draupnir-project/Draupnir 8 | // 9 | 10 | import { 11 | MatrixInterfaceCommandDispatcher, 12 | StandardMatrixInterfaceCommandDispatcher, 13 | JSInterfaceCommandDispatcher, 14 | BasicInvocationInformation, 15 | StandardJSInterfaceCommandDispatcher, 16 | CommandNormaliser, 17 | makeCommandNormaliser, 18 | } from "@the-draupnir-project/interface-manager"; 19 | import { Draupnir } from "../Draupnir"; 20 | import { DraupnirHelpCommand } from "./Help"; 21 | import { StringUserID } from "@the-draupnir-project/matrix-basic-types"; 22 | import { DraupnirTopLevelCommands } from "./DraupnirCommandTable"; 23 | import { 24 | DraupnirContextToCommandContextTranslator, 25 | DraupnirInterfaceAdaptor, 26 | } from "./DraupnirCommandPrerequisites"; 27 | import "./DraupnirCommands"; 28 | import { IConfig } from "../config"; 29 | import { 30 | MatrixEventContext, 31 | invocationInformationFromMatrixEventcontext, 32 | MPSCommandDispatcherCallbacks, 33 | } from "@the-draupnir-project/mps-interface-adaptor"; 34 | 35 | export function makeDraupnirCommandNormaliser( 36 | clientUserID: StringUserID, 37 | displayNameIssuer: { clientDisplayName: string }, 38 | config: IConfig 39 | ): CommandNormaliser { 40 | return makeCommandNormaliser(clientUserID, { 41 | symbolPrefixes: config.commands.symbolPrefixes, 42 | isAllowedOnlySymbolPrefixes: config.commands.allowNoPrefix, 43 | additionalPrefixes: ["draupnir", ...config.commands.additionalPrefixes], 44 | getDisplayName: function (): string { 45 | return displayNameIssuer.clientDisplayName; 46 | }, 47 | normalisedPrefix: "draupnir", 48 | }); 49 | } 50 | 51 | export function makeDraupnirCommandDispatcher( 52 | draupnir: Draupnir 53 | ): MatrixInterfaceCommandDispatcher { 54 | return new StandardMatrixInterfaceCommandDispatcher( 55 | DraupnirInterfaceAdaptor, 56 | draupnir, 57 | DraupnirTopLevelCommands, 58 | DraupnirHelpCommand, 59 | invocationInformationFromMatrixEventcontext, 60 | { 61 | ...MPSCommandDispatcherCallbacks, 62 | commandNormaliser: makeDraupnirCommandNormaliser( 63 | draupnir.clientUserID, 64 | draupnir, 65 | draupnir.config 66 | ), 67 | } 68 | ); 69 | } 70 | 71 | export function makeDraupnirJSCommandDispatcher( 72 | draupnir: Draupnir 73 | ): JSInterfaceCommandDispatcher { 74 | return new StandardJSInterfaceCommandDispatcher( 75 | DraupnirTopLevelCommands, 76 | DraupnirHelpCommand, 77 | draupnir, 78 | { 79 | ...MPSCommandDispatcherCallbacks, 80 | commandNormaliser: makeDraupnirCommandNormaliser( 81 | draupnir.clientUserID, 82 | draupnir, 83 | draupnir.config 84 | ), 85 | }, 86 | DraupnirContextToCommandContextTranslator 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /test/unit/stores/userRestrictionAuditLogTest.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import Database from "better-sqlite3"; 6 | import { BetterSqliteOptions } from "../../../src/backingstore/better-sqlite3/BetterSqliteStore"; 7 | import { 8 | describePolicyRule, 9 | LiteralPolicyRule, 10 | parsePolicyRule, 11 | PolicyRuleType, 12 | randomRoomID, 13 | randomUserID, 14 | Recommendation, 15 | } from "matrix-protection-suite"; 16 | import expect from "expect"; 17 | import { AccountRestriction } from "matrix-protection-suite-for-matrix-bot-sdk"; 18 | import { SqliteUserRestrictionAuditLog } from "../../../src/protections/HomeserverUserPolicyApplication/SqliteUserRestrictionAuditLog"; 19 | 20 | describe("UserAuditLog test", function () { 21 | const options = { path: ":memory:" } satisfies BetterSqliteOptions; 22 | const db = new Database(options.path); 23 | db.pragma("FOREIGN_KEYS = ON"); 24 | const store = new SqliteUserRestrictionAuditLog(db); 25 | it("Can logged suspended users", async function () { 26 | const bannedUser = randomUserID(); 27 | const policyRoom = randomRoomID([]); 28 | const moderator = randomUserID(); 29 | expect( 30 | (await store.isUserRestricted(bannedUser)).expect( 31 | "Should be able to query if user is suspended" 32 | ) 33 | ).toBe(false); 34 | const ban = parsePolicyRule( 35 | describePolicyRule({ 36 | room_id: policyRoom.toRoomIDOrAlias(), 37 | entity: bannedUser, 38 | reason: "spam", 39 | recommendation: Recommendation.Ban, 40 | type: PolicyRuleType.User, 41 | }) as never 42 | ).expect("Should be able to parse the policy rule."); 43 | ( 44 | await store.recordUserRestriction( 45 | bannedUser, 46 | AccountRestriction.Suspended, 47 | { 48 | sender: moderator, 49 | rule: ban as LiteralPolicyRule, 50 | } 51 | ) 52 | ).expect("Should be able to takedown a room"); 53 | expect( 54 | (await store.isUserRestricted(bannedUser)).expect( 55 | "Should be able to query if user is suspended" 56 | ) 57 | ).toBe(true); 58 | // now unsuspend them 59 | (await store.unrestrictUser(bannedUser, moderator)).expect( 60 | "Should be able to unsuspend a user" 61 | ); 62 | expect( 63 | (await store.isUserRestricted(bannedUser)).expect( 64 | "Should be able to query if user is suspended" 65 | ) 66 | ).toBe(false); 67 | }); 68 | it("Can log suspended users even without a policy (when a command is used)", async function () { 69 | const bannedUser = randomUserID(); 70 | const moderator = randomUserID(); 71 | ( 72 | await store.recordUserRestriction( 73 | bannedUser, 74 | AccountRestriction.Suspended, 75 | { 76 | sender: moderator, 77 | rule: null, 78 | } 79 | ) 80 | ).expect("To be able to do this"); 81 | expect( 82 | (await store.isUserRestricted(bannedUser)).expect( 83 | "Should be able to query if user is suspended" 84 | ) 85 | ).toBe(true); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/backingstore/DraupnirStores.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | import { EventDecoder, SHA256HashStore } from "matrix-protection-suite"; 6 | import { RoomAuditLog } from "../protections/RoomTakedown/RoomAuditLog"; 7 | import { SqliteRoomStateBackingStore } from "./better-sqlite3/SqliteRoomStateBackingStore"; 8 | import { SqliteHashReversalStore } from "./better-sqlite3/HashStore"; 9 | import { SqliteRoomAuditLog } from "../protections/RoomTakedown/SqliteRoomAuditLog"; 10 | import { UserRestrictionAuditLog } from "../protections/HomeserverUserPolicyApplication/UserRestrictionAuditLog"; 11 | import { SqliteUserRestrictionAuditLog } from "../protections/HomeserverUserPolicyApplication/SqliteUserRestrictionAuditLog"; 12 | 13 | export type TopLevelStores = { 14 | hashStore?: SHA256HashStore; 15 | roomAuditLog?: RoomAuditLog | undefined; 16 | roomStateBackingStore?: SqliteRoomStateBackingStore | undefined; 17 | userRestrictionAuditLog?: UserRestrictionAuditLog | undefined; 18 | dispose(): void; 19 | }; 20 | 21 | /** 22 | * These stores will usually be created at the entrypoint of the draupnir 23 | * application or attenuated for each draupnir. 24 | * 25 | * No i don't like it. Some of these stores ARE specific to the draupnir 26 | * such as the hasStore's event emitter... that just can't be mixed. 27 | * 28 | * We could create a wrapper that disposes of only the stores that 29 | * have been attenuated... but i don't know about it. 30 | */ 31 | export type DraupnirStores = { 32 | hashStore?: SHA256HashStore | undefined; 33 | roomAuditLog?: RoomAuditLog | undefined; 34 | userRestrictionAuditLog?: UserRestrictionAuditLog | undefined; 35 | /** 36 | * Dispose of stores relevant to a specific draupnir instance. 37 | * For example, the hash store is usually specific to a single draupnir. 38 | */ 39 | dispose(): void; 40 | }; 41 | 42 | export function createDraupnirStores( 43 | topLevelStores: TopLevelStores 44 | ): DraupnirStores { 45 | return Object.freeze({ 46 | roomAuditLog: topLevelStores.roomAuditLog, 47 | hashStore: topLevelStores.hashStore, 48 | userRestrictionAuditLog: topLevelStores.userRestrictionAuditLog, 49 | dispose() {}, 50 | } satisfies DraupnirStores); 51 | } 52 | 53 | export function makeTopLevelStores( 54 | storagePath: string, 55 | eventDecoder: EventDecoder, 56 | { 57 | isRoomStateBackingStoreEnabled, 58 | }: { isRoomStateBackingStoreEnabled: boolean } 59 | ): TopLevelStores { 60 | return Object.freeze({ 61 | roomStateBackingStore: isRoomStateBackingStoreEnabled 62 | ? SqliteRoomStateBackingStore.create(storagePath, eventDecoder) 63 | : undefined, 64 | hashStore: SqliteHashReversalStore.createToplevel(storagePath), 65 | roomAuditLog: SqliteRoomAuditLog.createToplevel(storagePath), 66 | userRestrictionAuditLog: 67 | SqliteUserRestrictionAuditLog.createToplevel(storagePath), 68 | dispose() { 69 | this.roomStateBackingStore?.destroy(); 70 | this.hashStore?.destroy(); 71 | this.roomAuditLog?.destroy(); 72 | this.userRestrictionAuditLog?.destroy(); 73 | }, 74 | } satisfies TopLevelStores); 75 | } 76 | -------------------------------------------------------------------------------- /src/queues/ProtectedRoomActivityTracker.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // Copyright 2022 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { StringRoomID } from "@the-draupnir-project/matrix-basic-types"; 12 | import { RoomEvent } from "matrix-protection-suite"; 13 | 14 | /** 15 | * Used to keep track of protected rooms so they are always ordered for activity. 16 | * 17 | * We use the same method as Element web for this, the major disadvantage being that we sort on each access to the room list (sort by most recently active first). 18 | * We have tried to mitigate this by caching the sorted list until the activity in rooms changes again. 19 | * See https://github.com/matrix-org/matrix-react-sdk/blob/8a0398b632dff1a5f6cfd4bf95d78854aeadc60e/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts 20 | * 21 | */ 22 | export class ProtectedRoomActivityTracker { 23 | private protectedRoomActivities = new Map< 24 | string /*room id*/, 25 | number /*last event timestamp*/ 26 | >(); 27 | /** 28 | * A slot to cache the rooms for `protectedRoomsByActivity` ordered so the most recently active room is first. 29 | */ 30 | private activeRoomsCache: null | string[] = null; 31 | 32 | /** 33 | * Inform the tracker that a new room is being protected by Draupnir. 34 | * @param roomId The room Draupnir is now protecting. 35 | */ 36 | public addProtectedRoom(roomId: string): void { 37 | this.protectedRoomActivities.set(roomId, /* epoch */ 0); 38 | this.activeRoomsCache = null; 39 | } 40 | 41 | /** 42 | * Inform the trakcer that a room is no longer being protected by Draupnir. 43 | * @param roomId The roomId that is no longer being protected by Draupnir. 44 | */ 45 | public removeProtectedRoom(roomId: string): void { 46 | this.protectedRoomActivities.delete(roomId); 47 | this.activeRoomsCache = null; 48 | } 49 | 50 | /** 51 | * Inform the tracker of a new event in a room, so that the internal ranking of rooms can be updated 52 | * @param roomId The room the new event is in. 53 | * @param event The new event. 54 | * 55 | */ 56 | public handleEvent(roomID: StringRoomID, event: RoomEvent): void { 57 | const last_origin_server_ts = this.protectedRoomActivities.get(roomID); 58 | if ( 59 | last_origin_server_ts !== undefined && 60 | Number.isInteger(event.origin_server_ts) 61 | ) { 62 | if (event.origin_server_ts > last_origin_server_ts) { 63 | this.activeRoomsCache = null; 64 | this.protectedRoomActivities.set(roomID, event.origin_server_ts); 65 | } 66 | } 67 | } 68 | 69 | /** 70 | * @returns A list of protected rooms ids ordered by activity. 71 | */ 72 | public protectedRoomsByActivity(): string[] { 73 | if (!this.activeRoomsCache) { 74 | this.activeRoomsCache = [...this.protectedRoomActivities] 75 | .sort((a, b) => b[1] - a[1]) 76 | .map((pair) => pair[0]); 77 | } 78 | return this.activeRoomsCache; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/unit/commands/WatchUnwatchCommandTest.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { 6 | MatrixRoomID, 7 | MatrixRoomReference, 8 | StringRoomID, 9 | } from "@the-draupnir-project/matrix-basic-types"; 10 | import expect from "expect"; 11 | import { 12 | Ok, 13 | PropagationType, 14 | RoomJoiner, 15 | WatchedPolicyRooms, 16 | isError, 17 | isOk, 18 | } from "matrix-protection-suite"; 19 | import { createMock } from "ts-auto-mock"; 20 | import { 21 | DraupnirUnwatchPolicyRoomCommand, 22 | DraupnirWatchPolicyRoomCommand, 23 | DraupnirWatchUnwatchCommandContext, 24 | } from "../../../src/commands/WatchUnwatchCommand"; 25 | import { CommandExecutorHelper } from "@the-draupnir-project/interface-manager"; 26 | 27 | describe("Test the WatchUnwatchCommmands", function () { 28 | const policyRoom = MatrixRoomReference.fromRoomID( 29 | "!room:example.com" as StringRoomID 30 | ); 31 | const watchedPolicyRooms = createMock({ 32 | async watchPolicyRoomDirectly(room) { 33 | expect(room).toBe(policyRoom); 34 | return Ok(undefined); 35 | }, 36 | }); 37 | const roomJoiner = createMock({ 38 | async joinRoom(roomReference) { 39 | if (roomReference instanceof MatrixRoomID) { 40 | return Ok(roomReference); 41 | } 42 | throw new TypeError(`We don't really expect to resolve anything`); 43 | }, 44 | async resolveRoom(roomReference) { 45 | if (roomReference instanceof MatrixRoomID) { 46 | return Ok(roomReference); 47 | } 48 | throw new TypeError(`We don't really expect to resolve anything`); 49 | }, 50 | }); 51 | it("DraupnirWatchCommand", async function () { 52 | const result = await CommandExecutorHelper.execute( 53 | DraupnirWatchPolicyRoomCommand, 54 | createMock({ 55 | watchedPolicyRooms, 56 | roomJoiner, 57 | }), 58 | { keywords: { "no-confirm": true } }, 59 | policyRoom 60 | ); 61 | expect(isOk(result)).toBe(true); 62 | }); 63 | it("Draupnir watch command should return an error if the room is already being watched", async function () { 64 | const issuerManagerWithWatchedList = createMock({ 65 | allRooms: [{ room: policyRoom, propagation: PropagationType.Direct }], 66 | }); 67 | const result = await CommandExecutorHelper.execute( 68 | DraupnirWatchPolicyRoomCommand, 69 | createMock({ 70 | watchedPolicyRooms: issuerManagerWithWatchedList, 71 | roomJoiner, 72 | }), 73 | { keywords: { "no-confirm": true } }, 74 | policyRoom 75 | ); 76 | expect(isError(result)).toBe(true); 77 | }); 78 | it("DraupnirUnwatchCommand", async function () { 79 | const result = await CommandExecutorHelper.execute( 80 | DraupnirUnwatchPolicyRoomCommand, 81 | createMock({ 82 | watchedPolicyRooms, 83 | roomJoiner, 84 | }), 85 | { keywords: { "no-confirm": true } }, 86 | policyRoom 87 | ); 88 | expect(isOk(result)).toBe(true); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /test/appservice/integration/provisionTest.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Gnuxie 2 | // Copyright 2022 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { isPolicyRoom, readTestConfig, setupHarness } from "../utils/harness"; 12 | import { newTestUser } from "../../integration/clientHelper"; 13 | import { getFirstReply } from "../../integration/commands/commandUtils"; 14 | import { MatrixClient } from "matrix-bot-sdk"; 15 | import { MjolnirAppService } from "../../../src/appservice/AppService"; 16 | 17 | interface Context extends Mocha.Context { 18 | moderator?: MatrixClient; 19 | appservice?: MjolnirAppService | undefined; 20 | } 21 | 22 | describe("Test that the app service can provision a draupnir on invite of the appservice bot", function () { 23 | afterEach(function (this: Context) { 24 | this.moderator?.stop(); 25 | if (this.appservice) { 26 | return this.appservice.close(); 27 | } else { 28 | console.warn("Missing Appservice in this context, so cannot stop it."); 29 | return Promise.resolve(); // TS7030: Not all code paths return a value. 30 | } 31 | }); 32 | it("A moderator that requests a draupnir via a matrix invitation will be invited to a new policy and management room", async function (this: Context) { 33 | const config = readTestConfig(); 34 | this.appservice = await setupHarness(); 35 | const appservice = this.appservice; 36 | // create a user to act as the moderator 37 | const moderator = await newTestUser(config.homeserver.url, { 38 | name: { contains: "test" }, 39 | }); 40 | const roomWeWantProtecting = await moderator.createRoom(); 41 | // have the moderator invite the appservice bot in order to request a new draupnir 42 | this.moderator = moderator; 43 | const roomsInvitedTo: string[] = []; 44 | await new Promise((resolve) => { 45 | void (async () => { 46 | moderator.on("room.invite", (roomId: string) => { 47 | roomsInvitedTo.push(roomId); 48 | // the appservice should invite the moderator to a policy room and a management room. 49 | if (roomsInvitedTo.length === 2) { 50 | resolve(null); 51 | } 52 | }); 53 | await moderator.start(); 54 | await moderator.inviteUser( 55 | appservice.bridge.getBot().getUserId(), 56 | roomWeWantProtecting 57 | ); 58 | })(); 59 | }); 60 | await Promise.all( 61 | roomsInvitedTo.map((roomId) => moderator.joinRoom(roomId)) 62 | ); 63 | const managementRoomId = roomsInvitedTo.filter( 64 | async (roomId) => !(await isPolicyRoom(moderator, roomId)) 65 | )[0]; 66 | if (managementRoomId === undefined) { 67 | throw new TypeError(`Unable to find management room`); 68 | } 69 | // Check that the newly provisioned draupnir is actually responsive. 70 | await getFirstReply(moderator, managementRoomId, () => { 71 | return moderator.sendMessage(managementRoomId, { 72 | body: `!draupnir status`, 73 | msgtype: "m.text", 74 | }); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /test/integration/reportPollingTest.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 - 2024 Gnuxie 2 | // Copyright 2022 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { MatrixClient } from "matrix-bot-sdk"; 12 | import { newTestUser } from "./clientHelper"; 13 | import { DraupnirTestContext } from "./mjolnirSetupUtils"; 14 | import { 15 | MatrixRoomReference, 16 | StringRoomID, 17 | } from "@the-draupnir-project/matrix-basic-types"; 18 | import { randomUUID } from "crypto"; 19 | import expect from "expect"; 20 | import { createMock } from "ts-auto-mock"; 21 | import { ReportManager } from "../../src/report/ReportManager"; 22 | import { ReportPoller } from "../../src/report/ReportPoller"; 23 | 24 | describe("Test: Report polling", function () { 25 | let client: MatrixClient; 26 | let reportPoller: ReportPoller | undefined; 27 | this.beforeEach(async function () { 28 | client = await newTestUser(this.config.homeserverUrl, { 29 | name: { contains: "protection-settings" }, 30 | }); 31 | }); 32 | this.afterEach(function () { 33 | reportPoller?.stop(); 34 | }); 35 | it("Draupnir correctly retrieves a report from synapse", async function ( 36 | this: DraupnirTestContext 37 | ) { 38 | const draupnir = this.draupnir; 39 | if (draupnir === undefined) { 40 | throw new TypeError(`Test didn't setup properly`); 41 | } 42 | const protectedRoomId = await draupnir.client.createRoom({ 43 | invite: [await client.getUserId()], 44 | }); 45 | await client.joinRoom(protectedRoomId); 46 | await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom( 47 | MatrixRoomReference.fromRoomID(protectedRoomId as StringRoomID) 48 | ); 49 | const testReportReason = randomUUID(); 50 | const reportsFound = new Set(); 51 | const duplicateReports = new Set(); 52 | const reportManager = createMock({ 53 | handleServerAbuseReport({ event, reason }) { 54 | if (reason === testReportReason) { 55 | if (reportsFound.has(event.event_id)) { 56 | duplicateReports.add(event.event_id); 57 | } 58 | reportsFound.add(event.event_id); 59 | } 60 | return Promise.resolve(undefined); 61 | }, 62 | }); 63 | reportPoller = new ReportPoller(draupnir, reportManager, { 64 | pollPeriod: 500, 65 | }); 66 | const reportEvent = async () => { 67 | const eventId = await client.sendMessage(protectedRoomId, { 68 | msgtype: "m.text", 69 | body: "uwNd3q", 70 | }); 71 | await client.doRequest( 72 | "POST", 73 | `/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, 74 | "", 75 | { 76 | reason: testReportReason, 77 | } 78 | ); 79 | }; 80 | reportPoller.start({ from: 1 }); 81 | for (let i = 0; i < 20; i++) { 82 | await reportEvent(); 83 | } 84 | // wait for them to come down the poll. 85 | await new Promise((resolve) => setTimeout(resolve, 3000)); 86 | expect(reportsFound.size).toBe(20); 87 | expect(duplicateReports.size).toBe(0); 88 | } as unknown as Mocha.AsyncFunc); 89 | }); 90 | -------------------------------------------------------------------------------- /src/protections/ProtectedRooms/UnprotectPartedRooms.tsx: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { 6 | MatrixRoomReference, 7 | StringRoomID, 8 | StringUserID, 9 | } from "@the-draupnir-project/matrix-basic-types"; 10 | import { 11 | isOk, 12 | MembershipChange, 13 | MembershipChangeType, 14 | ProtectedRoomsManager, 15 | RoomMessageSender, 16 | Task, 17 | } from "matrix-protection-suite"; 18 | import { 19 | renderMentionPill, 20 | renderRoomPill, 21 | sendMatrixEventsFromDeadDocument, 22 | } from "@the-draupnir-project/mps-interface-adaptor"; 23 | import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager"; 24 | 25 | export class UnprotectPartedRooms { 26 | constructor( 27 | private readonly clientUserID: StringUserID, 28 | private readonly managementRoomID: StringRoomID, 29 | private readonly protectedRoomsManager: ProtectedRoomsManager, 30 | private readonly messageSender: RoomMessageSender 31 | ) { 32 | // nothing to do. 33 | } 34 | 35 | public async handlePartedRoom(change: MembershipChange): Promise { 36 | const room = MatrixRoomReference.fromRoomID(change.roomID); 37 | const unprotectResult = await this.protectedRoomsManager.removeRoom(room); 38 | const removalDescription = ( 39 | 40 | Draupnir has been removed from {renderRoomPill(room)} by{" "} 41 | {renderMentionPill(change.sender, change.sender)} 42 | {change.content.reason ? ( 43 | 44 | {" "} 45 | for reason: {change.content.reason} 46 | 47 | ) : ( 48 | "" 49 | )} 50 | . 51 | 52 | ); 53 | if (isOk(unprotectResult)) { 54 | void Task( 55 | sendMatrixEventsFromDeadDocument( 56 | this.messageSender, 57 | this.managementRoomID, 58 | {removalDescription} The room is now unprotected., 59 | {} 60 | ), 61 | { 62 | description: 63 | "Report recently unprotected rooms to the management room.", 64 | } 65 | ); 66 | } else { 67 | void Task( 68 | sendMatrixEventsFromDeadDocument( 69 | this.messageSender, 70 | this.managementRoomID, 71 | 72 | {removalDescription} Draupnir could not unprotect the room. Please 73 | use !draupnir rooms remove {room.toRoomIDOrAlias()} if 74 | the room is still marked as protected. 75 | , 76 | {} 77 | ), 78 | { 79 | description: 80 | "Report recently unprotected rooms to the management room.", 81 | } 82 | ); 83 | } 84 | } 85 | 86 | public handleMembershipChange(change: MembershipChange): void { 87 | if (change.userID === this.clientUserID) { 88 | if (!this.protectedRoomsManager.isProtectedRoom(change.roomID)) { 89 | return; 90 | } 91 | switch (change.membershipChangeType) { 92 | case MembershipChangeType.Banned: 93 | case MembershipChangeType.Kicked: 94 | case MembershipChangeType.Left: 95 | void this.handlePartedRoom(change); 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/webapis/SynapseHTTPAntispam/SynapseHttpAntispam.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { Express, Request, Response } from "express"; 6 | import { SpamCheckEndpointPluginManager } from "./SpamCheckEndpointPluginManager"; 7 | import { 8 | UserMayInviteEndpoint, 9 | UserMayInviteListenerArguments, 10 | } from "./UserMayInviteEndpoint"; 11 | import { 12 | UserMayJoinRoomEndpoint, 13 | UserMayJoinRoomListenerArguments, 14 | } from "./UserMayJoinRoomEndpoint"; 15 | import { 16 | CheckEventForSpamEndpoint, 17 | CheckEventForSpamListenerArguments, 18 | } from "./CheckEventForSpamEndpoint"; 19 | import { handleHttpAntispamPing } from "./PingEndpoint"; 20 | 21 | const SPAM_CHECK_PREFIX = "/api/1/spam_check"; 22 | const AUTHORIZATION = new RegExp("Bearer (.*)"); 23 | 24 | function makeAuthenticatedEndpointHandler( 25 | secret: string, 26 | cb: (request: Request, response: Response) => void 27 | ): (request: Request, response: Response) => void { 28 | return function (request, response) { 29 | const authorization = request.get("Authorization"); 30 | if (!authorization) { 31 | response.status(401).send("Missing access token"); 32 | return; 33 | } 34 | const [, accessToken] = AUTHORIZATION.exec(authorization) ?? []; 35 | if (accessToken !== secret) { 36 | response.status(401).send("Missing access token"); 37 | return; 38 | } 39 | cb(request, response); 40 | }; 41 | } 42 | 43 | export class SynapseHttpAntispam { 44 | public readonly userMayInviteHandles = 45 | new SpamCheckEndpointPluginManager(); 46 | private readonly userMayInviteEndpoint = new UserMayInviteEndpoint( 47 | this.userMayInviteHandles 48 | ); 49 | public readonly userMayJoinRoomHandles = 50 | new SpamCheckEndpointPluginManager(); 51 | private readonly userMayJoinRoomEndpoint = new UserMayJoinRoomEndpoint( 52 | this.userMayJoinRoomHandles 53 | ); 54 | public readonly checkEventForSpamHandles = 55 | new SpamCheckEndpointPluginManager(); 56 | private readonly checkEventForSpamEndpoint = new CheckEventForSpamEndpoint( 57 | this.checkEventForSpamHandles 58 | ); 59 | public constructor(private readonly secret: string) { 60 | // nothing to do 61 | } 62 | 63 | public register(webController: Express): void { 64 | webController.post( 65 | `${SPAM_CHECK_PREFIX}/user_may_invite`, 66 | makeAuthenticatedEndpointHandler(this.secret, (request, response) => { 67 | this.userMayInviteEndpoint.handleUserMayInvite(request, response); 68 | }) 69 | ); 70 | webController.post( 71 | `${SPAM_CHECK_PREFIX}/user_may_join_room`, 72 | makeAuthenticatedEndpointHandler(this.secret, (request, response) => { 73 | this.userMayJoinRoomEndpoint.handleUserMayJoinRoom(request, response); 74 | }) 75 | ); 76 | webController.post( 77 | `${SPAM_CHECK_PREFIX}/check_event_for_spam`, 78 | makeAuthenticatedEndpointHandler(this.secret, (request, response) => { 79 | this.checkEventForSpamEndpoint.handleCheckEventForSpam( 80 | request, 81 | response 82 | ); 83 | }) 84 | ); 85 | webController.post( 86 | `${SPAM_CHECK_PREFIX}/ping`, 87 | makeAuthenticatedEndpointHandler(this.secret, handleHttpAntispamPing) 88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/queues/LeakyBucket.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 - 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | 5 | export interface LeakyBucket { 6 | /** 7 | * Return the token count. 8 | */ 9 | addToken(key: Key): number; 10 | getTokenCount(key: Key): number; 11 | getAllTokens(): Map; 12 | stop(): void; 13 | } 14 | 15 | type BucketEntry = { 16 | tokens: number; 17 | lastLeak: Date; 18 | }; 19 | 20 | /** 21 | * A lazy version of the bucket to be used when the throuhgput is really 22 | * low most of the time, so doesn't warrant constant filling/leaking. 23 | * 24 | * This won't be good to use in a high throughput situation because 25 | * of the way it will spam calling for the current time. 26 | */ 27 | export class LazyLeakyBucket implements LeakyBucket { 28 | private readonly buckets: Map = new Map(); 29 | private readonly leakDelta: number; 30 | private isDisposed = false; 31 | private leakCycleTimeout: NodeJS.Timeout | null = null; 32 | 33 | public constructor( 34 | private readonly capacity: number, 35 | private readonly timescale: number 36 | ) { 37 | this.leakDelta = this.timescale / this.capacity; 38 | this.startLeakCycle(); 39 | } 40 | getAllTokens(): Map { 41 | const map = new Map(); 42 | for (const key of this.buckets.keys()) { 43 | map.set(key, this.getTokenCount(key)); 44 | } 45 | return map; 46 | } 47 | 48 | private leak(now: Date, key: Key, entry: BucketEntry): void { 49 | const elapsed = now.getTime() - entry.lastLeak.getTime(); 50 | const tokensToRemove = Math.floor(elapsed / this.timescale); 51 | entry.tokens = Math.max(entry.tokens - tokensToRemove, 0); 52 | entry.lastLeak = new Date( 53 | entry.lastLeak.getTime() + tokensToRemove * this.leakDelta 54 | ); 55 | if (entry.tokens < 1) { 56 | this.buckets.delete(key); 57 | } 58 | } 59 | 60 | public addToken(key: Key): number { 61 | const now = new Date(); 62 | const entry = this.buckets.get(key); 63 | if (entry === undefined) { 64 | this.buckets.set(key, { 65 | tokens: 1, 66 | lastLeak: now, 67 | }); 68 | return 1; 69 | } 70 | entry.tokens += 1; 71 | this.leak(now, key, entry); 72 | return entry.tokens; 73 | } 74 | 75 | public getTokenCount(key: Key): number { 76 | const now = new Date(); 77 | const entry = this.buckets.get(key); 78 | if (entry === undefined) { 79 | return 0; 80 | } 81 | this.leak(now, key, entry); 82 | return entry.tokens; 83 | } 84 | 85 | private leakAll(): void { 86 | const now = new Date(); 87 | for (const [key, entry] of this.buckets.entries()) { 88 | this.leak(now, key, entry); 89 | } 90 | } 91 | 92 | /** 93 | * Periodically leak all of the buckets to prevent memory leaks from leftover 94 | * keys. 95 | */ 96 | private startLeakCycle(): void { 97 | if (this.isDisposed) { 98 | return; 99 | } 100 | this.leakCycleTimeout = setTimeout(() => { 101 | this.leakAll(); 102 | this.startLeakCycle(); 103 | }, this.timescale); 104 | } 105 | 106 | public stop(): void { 107 | this.isDisposed = true; 108 | if (this.leakCycleTimeout) { 109 | clearTimeout(this.leakCycleTimeout); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/capabilities/StandardEventConsequencesRenderer.tsx: -------------------------------------------------------------------------------- 1 | // Copyright 2022 - 2024 Gnuxie 2 | // Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. 3 | // 4 | // SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 5 | // 6 | // SPDX-FileAttributionText: 7 | // This modified file incorporates work from mjolnir 8 | // https://github.com/matrix-org/mjolnir 9 | // 10 | 11 | import { 12 | ActionResult, 13 | Capability, 14 | DescriptionMeta, 15 | EventConsequences, 16 | RoomEventRedacter, 17 | describeCapabilityContextGlue, 18 | describeCapabilityRenderer, 19 | isError, 20 | } from "matrix-protection-suite"; 21 | import { RendererMessageCollector } from "./RendererMessageCollector"; 22 | import { Draupnir } from "../Draupnir"; 23 | import { 24 | Permalinks, 25 | StringEventID, 26 | StringRoomID, 27 | } from "@the-draupnir-project/matrix-basic-types"; 28 | import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager"; 29 | import { renderFailedSingularConsequence } from "@the-draupnir-project/mps-interface-adaptor"; 30 | 31 | class StandardEventConsequencesRenderer implements EventConsequences { 32 | constructor( 33 | private readonly description: DescriptionMeta, 34 | private readonly messageCollector: RendererMessageCollector, 35 | private readonly capability: EventConsequences 36 | ) { 37 | // nothing to do. 38 | } 39 | public readonly requiredEventPermissions = 40 | this.capability.requiredEventPermissions; 41 | public readonly requiredPermissions = this.capability.requiredPermissions; 42 | public readonly requiredStatePermissions = 43 | this.capability.requiredStatePermissions; 44 | public async consequenceForEvent( 45 | roomID: StringRoomID, 46 | eventID: StringEventID, 47 | reason: string 48 | ): Promise> { 49 | const capabilityResult = await this.capability.consequenceForEvent( 50 | roomID, 51 | eventID, 52 | reason 53 | ); 54 | const title = ( 55 | Redacting {Permalinks.forEvent(roomID, eventID)}. 56 | ); 57 | if (isError(capabilityResult)) { 58 | this.messageCollector.addOneliner( 59 | this.description, 60 | this.capability, 61 | renderFailedSingularConsequence( 62 | this.description, 63 | title, 64 | capabilityResult.error 65 | ) 66 | ); 67 | return capabilityResult; 68 | } 69 | this.messageCollector.addOneliner(this.description, this.capability, title); 70 | return capabilityResult; 71 | } 72 | } 73 | 74 | describeCapabilityRenderer({ 75 | name: "StandardEventConsequences", 76 | description: "Renders the standard event consequences capability", 77 | interface: "EventConsequences", 78 | factory(description, draupnir, capability) { 79 | return new StandardEventConsequencesRenderer( 80 | description, 81 | draupnir.capabilityMessageRenderer, 82 | capability 83 | ); 84 | }, 85 | isDefaultForInterface: true, 86 | }); 87 | 88 | describeCapabilityContextGlue({ 89 | name: "StandardEventConsequences", 90 | glueMethod: function ( 91 | protectionDescription, 92 | draupnir, 93 | capabilityProvider 94 | ): Capability { 95 | return capabilityProvider.factory(protectionDescription, { 96 | eventRedacter: draupnir.clientPlatform.toRoomEventRedacter(), 97 | }); 98 | }, 99 | }); 100 | -------------------------------------------------------------------------------- /mx-tester.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 The Matrix.org Foundation C.I.C. 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | name: mjolnir 6 | 7 | up: 8 | before: 9 | - docker run --rm --network $MX_TEST_NETWORK_NAME --name 10 | mjolnir-test-postgres --domainname mjolnir-test-postgres -e 11 | POSTGRES_PASSWORD=mjolnir-test -e POSTGRES_USER=mjolnir-tester -e 12 | POSTGRES_DB=mjolnir-test-db -d -p 127.0.0.1:8083:5432 postgres 13 | # Wait until postgresql is ready 14 | - until psql 15 | postgres://mjolnir-tester:mjolnir-test@127.0.0.1:8083/mjolnir-test-db -c 16 | ""; do echo "Waiting for psql..."; sleep 1s; done 17 | # Launch the reverse proxy, listening for connections *only* on the local host. 18 | - docker run --rm --network host --name mjolnir-test-reverse-proxy -p 19 | 127.0.0.1:8081:80 -v $MX_TEST_CWD/test/nginx.conf:/etc/nginx/nginx.conf:ro 20 | -v 21 | $MX_TEST_CWD/test/draupnir_news.json:/var/www/test/draupnir_news.json:ro 22 | -d nginx 23 | - corepack yarn install 24 | - corepack yarn ts-node src/appservice/cli.ts -r -u 25 | "http://host.docker.internal:9000" 26 | - cp draupnir-registration.yaml $MX_TEST_SYNAPSE_DIR/data/ 27 | after: 28 | # Wait until Synapse is ready 29 | - until curl localhost:9999 --stderr /dev/null > /dev/null; do echo "Waiting 30 | for Synapse..."; sleep 1s; done 31 | - echo "Synapse is ready" 32 | 33 | run: 34 | - corepack yarn test:integration 35 | 36 | down: 37 | finally: 38 | - docker stop mjolnir-test-postgres || true 39 | - docker stop mjolnir-test-reverse-proxy || true 40 | 41 | modules: 42 | - name: HTTPAntispam 43 | build: 44 | - git clone https://github.com/maunium/synapse-http-antispam.git 45 | $MX_TEST_MODULE_DIR/ 46 | config: 47 | module: synapse_http_antispam.HTTPAntispam 48 | config: 49 | base_url: http://host.docker.internal:8082/api/1/spam_check 50 | authorization: DEFAULT 51 | do_ping: true 52 | enabled_callbacks: 53 | - user_may_invite 54 | - user_may_join_room 55 | - check_event_for_spam 56 | fail_open: 57 | user_may_invite: true 58 | user_may_join_room: true 59 | check_event_for_spam: true 60 | 61 | homeserver: 62 | # Basic configuration. 63 | server_name: localhost:9999 64 | public_baseurl: http://localhost:9999 65 | registration_shared_secret: REGISTRATION_SHARED_SECRET 66 | # Make manual testing easier 67 | enable_registration: true 68 | enable_registration_without_verification: true 69 | 70 | app_service_config_files: 71 | - "/data/draupnir-registration.yaml" 72 | 73 | # We remove rc_message so we can test rate limiting, 74 | # but we keep the others because of https://github.com/matrix-org/synapse/issues/11785 75 | # and we don't want to slow integration tests down. 76 | rc_registration: 77 | per_second: 10000 78 | burst_count: 10000 79 | 80 | rc_login: 81 | address: 82 | per_second: 10000 83 | burst_count: 10000 84 | account: 85 | per_second: 10000 86 | burst_count: 10000 87 | failed_attempts: 88 | per_second: 10000 89 | burst_count: 10000 90 | 91 | rc_admin_redaction: 92 | per_second: 10000 93 | burst_count: 10000 94 | 95 | rc_joins: 96 | local: 97 | per_second: 10000 98 | burst_count: 10000 99 | remote: 100 | per_second: 10000 101 | burst_count: 10000 102 | -------------------------------------------------------------------------------- /src/queues/TimelineRedactionQueue.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2025 Gnuxie 2 | // 3 | // SPDX-License-Identifier: AFL-3.0 4 | 5 | import { isError, Ok, Result } from "@gnuxie/typescript-result"; 6 | import { 7 | MatrixGlob, 8 | StringEventID, 9 | StringRoomID, 10 | StringUserID, 11 | } from "@the-draupnir-project/matrix-basic-types"; 12 | import { 13 | KeyedBatchQueue, 14 | Logger, 15 | RoomEventRedacter, 16 | RoomMessages, 17 | } from "matrix-protection-suite"; 18 | 19 | const log = new Logger("TimelineRedactionQueue"); 20 | 21 | export class TimelineRedactionQueue { 22 | private readonly batchProcessor = this.processBatch.bind(this); 23 | private readonly queue = new KeyedBatchQueue( 24 | this.batchProcessor 25 | ); 26 | 27 | public constructor( 28 | private readonly roomMessages: RoomMessages, 29 | private readonly roomEventRedacter: RoomEventRedacter, 30 | // FIXME: We really should have a way of adding a limit to the batch enqueue operation... 31 | // It doesn't really seem possible though. Unless the maximum limit anyone enqueues is chosen. 32 | // Yeah that sounds like how it would have to work. Meanwhile, it doesn't really matter since 33 | // the current behaviour is constrained to 1000. 34 | private readonly limit = 1000 35 | ) { 36 | // nothing to do. 37 | } 38 | private async processBatch( 39 | roomID: StringRoomID, 40 | userIDs: StringUserID[] 41 | ): Promise> { 42 | const globUserIDs = userIDs.filter( 43 | (userID) => userID.includes("*") || userID.includes("?") 44 | ); 45 | const globsToTest = globUserIDs.map((userID) => new MatrixGlob(userID)); 46 | const isGlobInUsers = globsToTest.length !== 0; 47 | const usersToTest = userIDs.filter( 48 | (userID) => !globUserIDs.includes(userID) 49 | ); 50 | const paginator = this.roomMessages.toRoomMessagesIterator(roomID, { 51 | direction: "backwards", 52 | limit: this.limit, 53 | ...(isGlobInUsers ? {} : { filter: { senders: userIDs } }), 54 | }); 55 | const eventsToRedact: StringEventID[] = []; 56 | const paginationResult = await paginator.forEachItem({ 57 | forEachItemCB: (event) => { 58 | if ( 59 | // Always add users when there are no globs, since events are filtered by sender. 60 | !isGlobInUsers || 61 | usersToTest.includes(event.sender) || 62 | globsToTest.some((glob) => glob.test(event.sender)) 63 | ) { 64 | eventsToRedact.push(event.event_id); 65 | } 66 | }, 67 | totalItemLimit: this.limit, 68 | }); 69 | if (isError(paginationResult)) { 70 | return paginationResult.elaborate( 71 | `Failed to paginate /messages in ${roomID} to begin redaction` 72 | ); 73 | } 74 | // TODO: It would be good if we had a way of throttling these requests 75 | // per draupnir and in general but y'know. 76 | for (const eventID of eventsToRedact) { 77 | const redactResult = await this.roomEventRedacter.redactEvent( 78 | roomID, 79 | eventID 80 | ); 81 | if (isError(redactResult)) { 82 | log.error( 83 | `Error while trying to redact messages for in ${roomID}:`, 84 | eventID, 85 | redactResult.error 86 | ); 87 | } 88 | } 89 | return Ok(undefined); 90 | } 91 | 92 | public async enqueueRedaction( 93 | userID: StringUserID, 94 | roomID: StringRoomID 95 | ): Promise> { 96 | return await this.queue.enqueue(roomID, userID); 97 | } 98 | } 99 | --------------------------------------------------------------------------------