diff --git a/package.json b/package.json index 34a254bc..b5f5826e 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,8 @@ "js-yaml": "^4.1.0", "jsdom": "^24.0.0", "matrix-appservice-bridge": "^9.0.1", - "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@0.20.0", - "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.20.0", + "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@0.22.0", + "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.22.0", "parse-duration": "^1.0.2", "pg": "^8.8.0", "shell-quote": "^1.7.3", diff --git a/src/Draupnir.ts b/src/Draupnir.ts index 040ac520..92f58c8a 100644 --- a/src/Draupnir.ts +++ b/src/Draupnir.ts @@ -25,7 +25,7 @@ limitations under the License. * are NOT distributed, contributed, committed, or licensed under the Apache License. */ -import { ActionResult, Client, ClientPlatform, ClientRooms, EventReport, LoggableConfigTracker, Logger, MatrixRoomID, MatrixRoomReference, Membership, MembershipEvent, Ok, PolicyRoomManager, ProtectedRoomsSet, RoomEvent, RoomMembershipManager, RoomMessage, RoomStateManager, StringRoomID, StringUserID, Task, TextMessageContent, Value, isError, isStringRoomAlias, isStringRoomID, serverName, userLocalpart } from "matrix-protection-suite"; +import { ActionResult, Client, ClientPlatform, ClientRooms, EventReport, LoggableConfigTracker, Logger, MatrixRoomID, MatrixRoomReference, MembershipEvent, Ok, PolicyRoomManager, ProtectedRoomsSet, RoomEvent, RoomMembershipManager, RoomMembershipRevisionIssuer, RoomMessage, RoomStateManager, StringRoomID, StringUserID, Task, TextMessageContent, Value, isError, isStringRoomAlias, isStringRoomID, serverName, userLocalpart } from "matrix-protection-suite"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; import { findCommandTable } from "./commands/interface-manager/InterfaceCommand"; import { ThrottlingQueue } from "./queues/ThrottlingQueue"; @@ -33,10 +33,9 @@ import ManagementRoomOutput from "./ManagementRoomOutput"; import { ReportPoller } from "./report/ReportPoller"; import { ReportManager } from "./report/ReportManager"; import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler"; -import { MatrixSendClient, SynapseAdminClient, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { MatrixSendClient, SynapseAdminClient } from "matrix-protection-suite-for-matrix-bot-sdk"; import { IConfig } from "./config"; import { COMMAND_PREFIX, DraupnirContext, extractCommandFromMessageBody, handleCommand } from "./commands/CommandHandler"; -import { htmlEscape } from "./utils"; import { LogLevel } from "matrix-bot-sdk"; import { ARGUMENT_PROMPT_LISTENER, DEFAUILT_ARGUMENT_PROMPT_LISTENER, makeListenerForArgumentPrompt as makeListenerForArgumentPrompt, makeListenerForPromptDefault } from "./commands/interface-manager/MatrixPromptForAccept"; import { RendererMessageCollector } from "./capabilities/RendererMessageCollector"; @@ -44,6 +43,7 @@ import { DraupnirRendererMessageCollector } from "./capabilities/DraupnirRendere import { renderProtectionFailedToStart } from "./protections/ProtectedRoomsSetRenderers"; import { draupnirStatusInfo, renderStatusInfo } from "./commands/StatusCommand"; import { renderMatrixAndSend } from "./commands/interface-manager/DeadDocumentMatrix"; +import { isInvitationForUser } from "./protections/invitation/inviteCore"; const log = new Logger('Draupnir'); @@ -97,13 +97,16 @@ export class Draupnir implements Client { public readonly policyRoomManager: PolicyRoomManager, public readonly roomMembershipManager: RoomMembershipManager, public readonly loggableConfigTracker: LoggableConfigTracker, + /** Mjolnir has a feature where you can choose to accept invitations from a space and not just the management room. */ + public readonly acceptInvitesFromRoom: MatrixRoomID, + public readonly acceptInvitesFromRoomIssuer: RoomMembershipRevisionIssuer, public readonly synapseAdminClient?: SynapseAdminClient, ) { this.managementRoomID = this.managementRoom.toRoomIDOrAlias(); this.managementRoomOutput = new ManagementRoomOutput( this.managementRoomID, this.clientUserID, this.client, this.config ); - this.reactionHandler = new MatrixReactionHandler(this.managementRoom.toRoomIDOrAlias(), client, clientUserID); + this.reactionHandler = new MatrixReactionHandler(this.managementRoom.toRoomIDOrAlias(), client, clientUserID, clientPlatform); this.reportManager = new ReportManager(this); if (config.pollReports) { this.reportPoller = new ReportPoller(this, this.reportManager); @@ -144,6 +147,34 @@ export class Draupnir implements Client { config: IConfig, loggableConfigTracker: LoggableConfigTracker ): Promise> { + const acceptInvitesFromRoom = await (async () => { + if (config.autojoinOnlyIfManager) { + return Ok(managementRoom) + } else { + if (config.acceptInvitesFromSpace === undefined) { + throw new TypeError(`You cannot leave config.acceptInvitesFromSpace undefined if you have disabled config.autojoinOnlyIfManager`); + } + const room = (() => { + if (isStringRoomID(config.acceptInvitesFromSpace) || isStringRoomAlias(config.acceptInvitesFromSpace)) { + return config.acceptInvitesFromSpace; + } else { + const parseResult = MatrixRoomReference.fromPermalink(config.acceptInvitesFromSpace); + if (isError(parseResult)) { + throw new TypeError(`config.acceptInvitesFromSpace: ${config.acceptInvitesFromSpace} needs to be a room id, alias or permalink`); + } + return parseResult.ok; + } + })(); + return await clientPlatform.toRoomJoiner().joinRoom(room); + } + })(); + if (isError(acceptInvitesFromRoom)) { + return acceptInvitesFromRoom; + } + const acceptInvitesFromRoomIssuer = await roomMembershipManager.getRoomMembershipRevisionIssuer(acceptInvitesFromRoom.ok); + if (isError(acceptInvitesFromRoomIssuer)) { + return acceptInvitesFromRoomIssuer; + } const draupnir = new Draupnir( client, clientUserID, @@ -156,6 +187,8 @@ export class Draupnir implements Client { policyRoomManager, roomMembershipManager, loggableConfigTracker, + acceptInvitesFromRoom.ok, + acceptInvitesFromRoomIssuer.ok, new SynapseAdminClient( client, clientUserID @@ -199,7 +232,9 @@ export class Draupnir implements Client { } public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { - Task(this.joinOnInviteListener(roomID, event)); + if (Value.Check(MembershipEvent, event) && isInvitationForUser(event, this.clientUserID)) { + this.protectedRoomsSet.handleExternalInvite(roomID, event); + } this.managementRoomMessageListener(roomID, event); this.reactionHandler.handleEvent(roomID, event); if (this.protectedRoomsSet.isProtectedRoom(roomID)) { @@ -239,73 +274,6 @@ export class Draupnir implements Client { this.reportManager.handleTimelineEvent(roomID, event); } - /** - * Adds a listener to the client that will automatically accept invitations. - * FIXME: This is just copied in from Mjolnir and there are plenty of places for uncaught exceptions that will cause havok. - * FIXME: MOVE TO A PROTECTION. - * @param {MatrixSendClient} client - * @param options By default accepts invites from anyone. - * @param {string} options.managementRoom The room to report ignored invitations to if `recordIgnoredInvites` is true. - * @param {boolean} options.recordIgnoredInvites Whether to report invites that will be ignored to the `managementRoom`. - * @param {boolean} options.autojoinOnlyIfManager Whether to only accept an invitation by a user present in the `managementRoom`. - * @param {string} options.acceptInvitesFromSpace A space of users to accept invites from, ignores invites form users not in this space. - */ - private async joinOnInviteListener(roomID: StringRoomID, event: RoomEvent): Promise { - if (Value.Check(MembershipEvent, event) && event.state_key === this.clientUserID) { - const inviteEvent = event; - const reportInvite = async () => { - if (!this.config.recordIgnoredInvites) return; // Nothing to do - - Task((async () => { - await this.client.sendMessage(this.managementRoomID, { - msgtype: "m.text", - body: `${inviteEvent.sender} has invited me to ${inviteEvent.room_id} but the config prevents me from accepting the invitation. ` - + `If you would like this room protected, use "!mjolnir rooms add ${inviteEvent.room_id}" so I can accept the invite.`, - format: "org.matrix.custom.html", - formatted_body: `${htmlEscape(inviteEvent.sender)} has invited me to ${htmlEscape(inviteEvent.room_id)} but the config prevents me from ` - + `accepting the invitation. If you would like this room protected, use !mjolnir rooms add ${htmlEscape(inviteEvent.room_id)} ` - + `so I can accept the invite.`, - }); - return Ok(undefined); - })()); - }; - - if (this.config.autojoinOnlyIfManager) { - const managementMembership = this.protectedRoomsSet.setMembership.getRevision(this.managementRoomID); - if (managementMembership === undefined) { - throw new TypeError(`Processing an invitation before the protected rooms set has properly initialized. Are we protecting the management room?`); - } - const senderMembership = managementMembership.membershipForUser(inviteEvent.sender); - if (senderMembership?.membership !== Membership.Join) return reportInvite(); // ignore invite - } else { - if (!(isStringRoomID(this.config.acceptInvitesFromSpace) || isStringRoomAlias(this.config.acceptInvitesFromSpace))) { - // FIXME: We need to do StringRoomID stuff at parse time of the config. - throw new TypeError(`${this.config.acceptInvitesFromSpace} is not a valid room ID or Alias`); - } - const spaceReference = MatrixRoomReference.fromRoomIDOrAlias(this.config.acceptInvitesFromSpace); - const spaceID = await resolveRoomReferenceSafe(this.client, spaceReference); - if (isError(spaceID)) { - await this.managementRoomOutput.logMessage(LogLevel.ERROR, 'Draupnir', `Unable to resolve the space ${spaceReference.toPermalink} from config.acceptInvitesFromSpace when trying to accept an invitation from ${inviteEvent.sender}`); - } - const spaceId = await this.client.resolveRoom(this.config.acceptInvitesFromSpace); - const spaceUserIds = await this.client.getJoinedRoomMembers(spaceId) - .catch(async e => { - if (e.body?.errcode === "M_FORBIDDEN") { - await this.managementRoomOutput.logMessage(LogLevel.ERROR, 'Mjolnir', `Mjolnir is not in the space configured for acceptInvitesFromSpace, did you invite it?`); - await this.client.joinRoom(spaceId); - return await this.client.getJoinedRoomMembers(spaceId); - } else { - return Promise.reject(e); - } - }); - if (!spaceUserIds.includes(inviteEvent.sender)) { - return reportInvite(); // ignore invite - } - } - await this.client.joinRoom(roomID); - } - } - /** * Start responding to events. * This will not start the appservice from listening and responding diff --git a/src/DraupnirBotMode.ts b/src/DraupnirBotMode.ts index fb048acd..889a5e7f 100644 --- a/src/DraupnirBotMode.ts +++ b/src/DraupnirBotMode.ts @@ -96,7 +96,7 @@ export async function makeDraupnirBotModeFromConfig( DefaultEventDecoder, backingStore ); - const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap); + const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap, DefaultEventDecoder); const draupnirFactory = new DraupnirFactory( clientsInRoomMap, clientCapabilityFactory, diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index e7f3e4eb..4e93540c 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -128,7 +128,7 @@ export class MjolnirAppService { clientProvider, eventDecoder ); - const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap); + const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap, eventDecoder); const botUserID = bridge.getBot().getUserId() as StringUserID; const clientRooms = await clientsInRoomMap.makeClientRooms( botUserID, diff --git a/src/appservice/bot/AppserviceCommandHandler.ts b/src/appservice/bot/AppserviceCommandHandler.ts index 5563a71d..2a4be172 100644 --- a/src/appservice/bot/AppserviceCommandHandler.ts +++ b/src/appservice/bot/AppserviceCommandHandler.ts @@ -57,7 +57,8 @@ export class AppserviceCommandHandler { this.reactionHandler = new MatrixReactionHandler( this.appservice.accessControlRoomID, this.appservice.bridge.getBot().getClient(), - this.appservice.botUserID + this.appservice.botUserID, + clientPlatform ); this.commandContext = { appservice: this.appservice, diff --git a/src/commands/interface-manager/MatrixHelpRenderer.tsx b/src/commands/interface-manager/MatrixHelpRenderer.tsx index 4d17715b..fe2938ec 100644 --- a/src/commands/interface-manager/MatrixHelpRenderer.tsx +++ b/src/commands/interface-manager/MatrixHelpRenderer.tsx @@ -10,7 +10,8 @@ import { DocumentNode } from "./DeadDocument"; import { renderMatrixAndSend } from "./DeadDocumentMatrix"; import { LogService } from "matrix-bot-sdk"; import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { ActionException, ActionResult, MatrixRoomReference, RoomEvent, StringRoomID, isError } from "matrix-protection-suite"; +import { ActionError, ActionException, ActionExceptionKind, ActionResult, MatrixRoomReference, Ok, RoomEvent, StringRoomID, Task, isError, isOk } from "matrix-protection-suite"; +import { renderDetailsNotice, renderElaborationTrail, renderExceptionTrail } from "../../capabilities/CommonRenderers"; function requiredArgument(argumentName: string): string { return `<${argumentName}>`; @@ -88,17 +89,62 @@ export async function renderHelp(client: MatrixSendClient, commandRoomID: String ); } -export const tickCrossRenderer: RendererSignature = async function tickCrossRenderer(this: MatrixInterfaceAdaptor, client: MatrixSendClient, commandRoomID: StringRoomID, event: RoomEvent, result: ActionResult): Promise { - const react = async (emote: string) => { +export async function reactToEventWithResult(client: MatrixSendClient, event: RoomEvent, result: ActionResult): Promise> { + // implement this so we can use it in the invitation protection + // then in the invitation protection makes ure we render when the listener fails + // then in the ban propagation protection also do this. + const react = async (emote: string): Promise> => { try { - await client.unstableApis.addReactionToEvent(commandRoomID, event['event_id'], emote); + await client.unstableApis.addReactionToEvent(event.room_id, event.event_id, emote); + return Ok(undefined); } catch (e) { - LogService.error("tickCrossRenderer", "Couldn't react to the event", event['event_id'], e); + return ActionException.Result(`tickCrossRenderer Couldn't react to the event ${event.event_id}`, { + exception: e, + exceptionKind: ActionExceptionKind.Unknown + }); } - } - if (result.isOkay) { - await react('✅') + }; + if (isOk(result)) { + return await react('✅'); } else { + return await react('❌'); + } +} + +export async function replyToEventWithErrorDetails(client: MatrixSendClient, event: RoomEvent, error: ActionError): Promise> { + try { + await renderMatrixAndSend( + +
+ {error.mostRelevantElaboration} + {renderDetailsNotice(error)} + {renderElaborationTrail(error)} + {renderExceptionTrail(error)} +
+
, + event.room_id, + event, + client, + ); + return Ok(undefined); + } catch (e) { + return ActionException.Result(`replyToEventIfError Couldn't send a reply to the event ${event.event_id}`, { + exception: e, + exceptionKind: ActionExceptionKind.Unknown + }); + } +} + +export function renderActionResultToEvent(client: MatrixSendClient, event: RoomEvent, result: ActionResult): void { + if (isError(result)) { + void Task(replyToEventWithErrorDetails(client, event, result.error)); + } + void Task(reactToEventWithResult(client, event, result)); +} + +export const tickCrossRenderer: RendererSignature = async function tickCrossRenderer(this: MatrixInterfaceAdaptor, client: MatrixSendClient, commandRoomID: StringRoomID, event: RoomEvent, result: ActionResult): Promise { + void Task(reactToEventWithResult(client, event, result)); + if (isError(result)) { if (result.error instanceof ArgumentParseError) { await renderMatrixAndSend( renderArgumentParseError(this.interfaceCommand, result.error), @@ -116,8 +162,6 @@ export const tickCrossRenderer: RendererSignature = } else { await client.replyNotice(commandRoomID, event, result.error.message); } - // reacting is way less important than communicating what happened, do it last. - await react('❌'); } } diff --git a/src/commands/interface-manager/MatrixReactionHandler.ts b/src/commands/interface-manager/MatrixReactionHandler.ts index a204401d..7333ee24 100644 --- a/src/commands/interface-manager/MatrixReactionHandler.ts +++ b/src/commands/interface-manager/MatrixReactionHandler.ts @@ -1,12 +1,12 @@ -/** - * Copyright (C) 2023 Gnuxie - * All rights reserved. - */ +// SPDX-FileCopyrightText: 2023 - 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 import { EventEmitter } from "stream"; -import { LogService } from "matrix-bot-sdk"; import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; -import { ReactionEvent, RoomEvent, StringRoomID, StringUserID, Value } from "matrix-protection-suite"; +import { ActionResult, ClientPlatform, Logger, ReactionEvent, RoomEvent, StringEventID, StringRoomID, StringUserID, Task, Value, isError } from "matrix-protection-suite"; + +const log = new Logger("MatrixReactionHandler"); const REACTION_ANNOTATION_KEY = 'ge.applied-langua.ge.draupnir.reaction_handler'; @@ -19,11 +19,16 @@ export type ReactionListener = ( annotatedEvent: RoomEvent ) => void; +export declare interface MatrixReactionHandlerListeners { + on(eventName: string, listener: ReactionListener): void; + emit(eventName: string, ...args: Parameters): void; +} + /** * A utility that can be associated with an `MatrixEmitter` to listen for * reactions to Matrix Events. The aim is to simplify reaction UX. */ -export class MatrixReactionHandler extends EventEmitter { +export class MatrixReactionHandler extends EventEmitter implements MatrixReactionHandlerListeners { public constructor( /** * The room the handler is for. Cannot be enabled for every room as the @@ -37,7 +42,8 @@ export class MatrixReactionHandler extends EventEmitter { /** * The user id of the client. Ignores reactions from this user */ - private readonly clientUserID: StringUserID + private readonly clientUserID: StringUserID, + private readonly clientPlatform: ClientPlatform ) { super(); } @@ -75,17 +81,17 @@ export class MatrixReactionHandler extends EventEmitter { } const reactionMap = annotation['reaction_map']; if (typeof reactionMap !== 'object' || reactionMap === null) { - LogService.warn('MatrixReactionHandler', `Missing reaction_map for the annotated event ${relatedEventId} in ${roomID}`); + log.warn( `Missing reaction_map for the annotated event ${relatedEventId} in ${roomID}`); return; } const listenerName = annotation['name']; if (typeof listenerName !== 'string') { - LogService.warn('MatrixReactionHandler', `The event ${relatedEventId} in ${roomID} is missing the name of the annotation`); + log.warn( `The event ${relatedEventId} in ${roomID} is missing the name of the annotation`); return; } const association = reactionMap[reactionKey]; if (association === undefined) { - LogService.info('MatrixReactionHandler', `There wasn't a defined key for ${reactionKey} on event ${relatedEventId} in ${roomID}`); + log.info( `There wasn't a defined key for ${reactionKey} on event ${relatedEventId} in ${roomID}`); return; } this.emit(listenerName, reactionKey, association, annotation['additional_context'], new Map(Object.entries(reactionMap)), annotatedEvent); @@ -119,7 +125,57 @@ export class MatrixReactionHandler extends EventEmitter { await [...reactionMap.keys()] .reduce((acc, key) => acc.then(_ => client.unstableApis.addReactionToEvent(roomId, eventId, key)), Promise.resolve() - ).catch(e => (LogService.error('MatrixReactionHandler', `Could not add reaction to event ${eventId}`, e), Promise.reject(e))); + ).catch(e => (log.error( `Could not add reaction to event ${eventId}`, e), Promise.reject(e))); + } + + public async completePrompt( + roomID: StringRoomID, + eventID: StringEventID, + reason?: string + ): Promise> { + const eventRelationsGetter = this.clientPlatform.toRoomEventRelationsGetter(); + const redacter = this.clientPlatform.toRoomEventRedacter(); + return await eventRelationsGetter.forEachRelation( + roomID, + eventID, + { + relationType: 'm.annotation', + eventType: 'm.reaction', + forEachCB: (event) => { + const key = event.content?.["m.relates_to"]?.key + // skip the bots own reactions that mark the event as complete + if (key === '✅' || key === '❌') { + return; + } + void Task((async function () { + redacter.redactEvent(roomID, event.event_id, reason) + })()) + } + } + ); + } + + /** + * Removes all reactions from the prompt event in an attempt to stop it being used further. + */ + public async cancelPrompt( + promptEvent: RoomEvent, + cancelReason?: string + ): Promise> { + const completeResult = await this.completePrompt( + promptEvent.room_id, + promptEvent.event_id, + cancelReason ?? 'prompt cancelled' + ); + if (isError(completeResult)) { + return completeResult; + } + void this.client.unstableApis.addReactionToEvent( + promptEvent.room_id, + promptEvent.event_id, + `🚫 Cancelled by ${promptEvent.sender}` + ).catch(e => log.error(`Could not send cancelled reaction event for prompt ${promptEvent.event_id} in ${promptEvent.room_id}`, e)); + return completeResult; } public static createItemizedReactionMap(items: string[]): ItemByReactionKey { diff --git a/src/protections/DefaultEnabledProtectionsMigration.ts b/src/protections/DefaultEnabledProtectionsMigration.ts index 5368d839..098d0cce 100644 --- a/src/protections/DefaultEnabledProtectionsMigration.ts +++ b/src/protections/DefaultEnabledProtectionsMigration.ts @@ -6,6 +6,7 @@ import { ActionError,ActionException,ActionExceptionKind,DRAUPNIR_SCHEMA_VERSION_KEY, MjolnirEnabledProtectionsEvent, MjolnirEnabledProtectionsEventType, Ok, SchemedDataManager, Value, findProtection } from "matrix-protection-suite"; import { RedactionSynchronisationProtection } from "./RedactionSynchronisation"; import { PolicyChangeNotification } from "./PolicyChangeNotification"; +import { JoinRoomsOnInviteProtection } from "./invitation/JoinRoomsOnInviteProtection"; export const DefaultEnabledProtectionsMigration = new SchemedDataManager([ async function enableBanPropagationByDefault(input) { @@ -95,5 +96,26 @@ export const DefaultEnabledProtectionsMigration = new SchemedDataManager +// +// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 +// +// SPDX-FileAttributionText: +// This modified file incorporates work from mjolnir +// https://github.com/matrix-org/mjolnir +// + +import { AbstractProtection, ActionError, ActionResult, MatrixRoomReference, MembershipEvent, Ok, ProtectedRoomsSet, ProtectionDescription, StandardDeduplicator, StringRoomID, Task, describeProtection, isError, serverName } from "matrix-protection-suite"; +import { Draupnir } from "../../Draupnir"; +import { DraupnirProtection } from "../Protection"; +import { isInvitationForUser, isSenderJoinedInRevision } from "./inviteCore"; +import { renderMatrixAndSend } from "../../commands/interface-manager/DeadDocumentMatrix"; +import { DocumentNode } from "../../commands/interface-manager/DeadDocument"; +import { JSXFactory } from "../../commands/interface-manager/JSXFactory"; +import { renderMentionPill, renderRoomPill } from "../../commands/interface-manager/MatrixHelpRenderer"; +import { renderFailedSingularConsequence } from "../../capabilities/CommonRenderers"; +import { ProtectroomsOnInvite } from "./ProtectRoomsOnInvite"; +import { WatchRoomsOnInvite } from "./WatchRoomsOnInvite"; + +export type JoinRoomsOnInviteProtectionCapabilities = {}; + +export type JoinRoomsOnInviteProtectionDescription = ProtectionDescription; + +export class JoinRoomsOnInviteProtection + extends AbstractProtection + implements DraupnirProtection< + JoinRoomsOnInviteProtectionDescription +> { + private readonly promptedToProtectedDeduplicator = new StandardDeduplicator(); + private readonly protectRoomsOnInvite = new ProtectroomsOnInvite( + this.draupnir, + this.protectedRoomsSet + ); + private readonly watchRoomsOnInvite = new WatchRoomsOnInvite( + this.draupnir, + this.protectedRoomsSet + ); + public constructor( + description: JoinRoomsOnInviteProtectionDescription, + capabilities: JoinRoomsOnInviteProtectionCapabilities, + protectedRoomsSet: ProtectedRoomsSet, + private readonly draupnir: Draupnir, + ) { + super( + description, + capabilities, + protectedRoomsSet, + {} + ) + } + + handleProtectionDisable(): void { + this.protectRoomsOnInvite.handleProtectionDisable(); + this.watchRoomsOnInvite.handleProtectionDisable(); + } + + handleExternalInvite(roomID: StringRoomID, event: MembershipEvent): void { + if (!isInvitationForUser(event, this.protectedRoomsSet.userID)) { + return; + } + // The event handler gets called again when we join the room we were invited to. + // As sometimes we get the invitation a second time from the join section of sync. + if (this.promptedToProtectedDeduplicator.isDuplicate(roomID)) { + return; + } + void Task(this.checkAgainstRequiredMembershipRoom(event)); + } + + private async checkAgainstRequiredMembershipRoom(event: MembershipEvent): Promise> { + const revision = this.draupnir.acceptInvitesFromRoomIssuer.currentRevision; + if (isSenderJoinedInRevision(event.sender, revision)) { + return await this.joinAndIssuePrompts(event); + } else { + this.reportUnknownInvite(event, revision.room); + return Ok(undefined); + } + } + + private reportUnknownInvite(event: MembershipEvent, requiredMembershipRoom: MatrixRoomReference): void { + const renderUnknownInvite = (): DocumentNode => { + return + {renderMentionPill(event.sender, event.sender)} has invited me to + {renderRoomPill(MatrixRoomReference.fromRoomID(event.room_id))} + but they are not joined to {renderRoomPill(requiredMembershipRoom)}, which prevents me from accepting their invitation.
+ If you would like this room protected, use !draupnir rooms add {event.room_id} +
+ } + void Task((async () => { + renderMatrixAndSend( + renderUnknownInvite(), + this.draupnir.managementRoomID, + undefined, + this.draupnir.client + ); + return Ok(undefined) + })()); + } + + private async joinInvitedRoom(event: MembershipEvent, room: MatrixRoomReference): Promise> { + const renderFailedTojoin = (error: ActionError) => { + const title = Unfortunatley I was unable to accept the invitation from {renderMentionPill(event.sender, event.sender)} to the room {renderRoomPill(room)}.; + return + {renderFailedSingularConsequence(this.description, title, error)} + + }; + const joinResult = await this.draupnir.clientPlatform.toRoomJoiner().joinRoom(room); + if (isError(joinResult)) { + await renderMatrixAndSend( + renderFailedTojoin(joinResult.error), + this.draupnir.managementRoomID, + undefined, + this.draupnir.client + ) + } + return joinResult; + } + + private async joinAndIssuePrompts(event: MembershipEvent): Promise> { + const invitedRoomReference = MatrixRoomReference.fromRoomID(event.room_id, [serverName(event.sender), serverName(event.state_key)]); + const joinResult = await this.joinInvitedRoom(event, invitedRoomReference); + if (isError(joinResult)) { + return joinResult; + } + void this.watchRoomsOnInvite.promptIfPossiblePolicyRoom(invitedRoomReference, event); + if (!this.draupnir.config.protectAllJoinedRooms && !this.protectedRoomsSet.isProtectedRoom(event.room_id)) { + void this.protectRoomsOnInvite.promptToProtect(invitedRoomReference, event); + } + return Ok(undefined); + } + +} + +describeProtection<{}, Draupnir>({ + name: JoinRoomsOnInviteProtection.name, + description: "Automatically joins rooms when invited by members of the management room and offers to protect them", + capabilityInterfaces: {}, + defaultCapabilities: {}, + factory(description, protectedRoomsSet, draupnir, capabilities, _settings) { + return Ok( + new JoinRoomsOnInviteProtection( + description, + capabilities, + protectedRoomsSet, + draupnir + ) + ) + } +}) diff --git a/src/protections/invitation/ProtectRoomsOnInvite.tsx b/src/protections/invitation/ProtectRoomsOnInvite.tsx new file mode 100644 index 00000000..1cd0c6bd --- /dev/null +++ b/src/protections/invitation/ProtectRoomsOnInvite.tsx @@ -0,0 +1,103 @@ +// Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. +// Copyright 2022 - 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 +// +// SPDX-FileAttributionText: +// This modified file incorporates work from mjolnir +// https://github.com/matrix-org/mjolnir +// + +import { Logger, MatrixRoomID, MembershipEvent, Ok, Permalink, ProtectedRoomsSet, RoomEvent, Task, Value, isError } from "matrix-protection-suite"; +import { DocumentNode } from "../../commands/interface-manager/DeadDocument"; +import { renderActionResultToEvent, renderMentionPill, renderRoomPill } from "../../commands/interface-manager/MatrixHelpRenderer"; +import { JSXFactory } from "../../commands/interface-manager/JSXFactory"; +import { renderMatrixAndSend } from "../../commands/interface-manager/DeadDocumentMatrix"; +import { StaticDecode, Type } from "@sinclair/typebox"; +import { Draupnir } from "../../Draupnir"; + +const log = new Logger('ProtectRoomsOnInvite'); + +const PROTECT_ROOMS_ON_INVITE_PROMPT_LISTENER = 'me.marewolf.draupnir.protect_rooms_on_invite'; + +// would be nice to be able to use presentation types here idk. +const ProtectRoomsOnInvitePromptContext = Type.Object({ + invited_room: Permalink +}); +// this rule is stupid. +// eslint-disable-next-line no-redeclare +type ProtectRoomsOnInvitePromptContext = StaticDecode; + +export class ProtectroomsOnInvite { + + private readonly protectPromptListener = this.protectListener.bind(this); + public constructor( + private readonly draupnir: Draupnir, + private readonly protectedRoomsSet: ProtectedRoomsSet, + ) { + this.draupnir.reactionHandler.on(PROTECT_ROOMS_ON_INVITE_PROMPT_LISTENER, this.protectPromptListener); + } + + handleProtectionDisable(): void { + this.draupnir.reactionHandler.off(PROTECT_ROOMS_ON_INVITE_PROMPT_LISTENER, this.protectPromptListener); + } + + public promptToProtect(candidateRoom: MatrixRoomID, invitation: MembershipEvent): void { + void Task((async () => { + const renderPromptProtect = (): DocumentNode => + + {renderMentionPill(invitation.sender, invitation.sender)} has invited me to + {renderRoomPill(candidateRoom)}, + would you like to protect this room? + ; + const reactionMap = new Map(Object.entries({ 'OK': 'OK', 'Cancel': 'Cancel' })); + const promptEventID = (await renderMatrixAndSend( + renderPromptProtect(), + this.draupnir.managementRoomID, + undefined, + this.draupnir.client, + this.draupnir.reactionHandler.createAnnotation( + PROTECT_ROOMS_ON_INVITE_PROMPT_LISTENER, + reactionMap, + { + invited_room: candidateRoom.toPermalink(), + } + ) + ))[0]; + await this.draupnir.reactionHandler.addReactionsToEvent(this.draupnir.client, this.draupnir.managementRoomID, promptEventID, reactionMap); + return Ok(undefined); + })()) + } + + private protectListener(key: string, _item: unknown, rawContext: unknown, _reactionMap: Map, promptEvent: RoomEvent): void { + if (key === 'Cancel') { + void Task(this.draupnir.reactionHandler.cancelPrompt(promptEvent)); + return; + } + if (key !== 'OK') { + return; + } + const context = Value.Decode(ProtectRoomsOnInvitePromptContext, rawContext); + if (isError(context)) { + log.error(`Could not decode context from prompt event`, context.error); + renderActionResultToEvent(this.draupnir.client, promptEvent, context); + return; + } + void Task((async () => { + const resolvedRoom = await this.draupnir.clientPlatform.toRoomResolver().resolveRoom(context.ok.invited_room); + if (isError(resolvedRoom)) { + resolvedRoom.elaborate(`Could not resolve the room to protect from the MatrixRoomReference: ${context.ok.invited_room.toPermalink()}.`); + renderActionResultToEvent(this.draupnir.client, promptEvent, resolvedRoom); + return; + } + const addResult = await this.protectedRoomsSet.protectedRoomsManager.addRoom(resolvedRoom.ok) + if (isError(addResult)) { + addResult.elaborate(`Could not protect the room: ${resolvedRoom.ok.toPermalink()}`); + renderActionResultToEvent(this.draupnir.client, promptEvent, addResult); + return; + } + renderActionResultToEvent(this.draupnir.client, promptEvent, addResult); + void Task(this.draupnir.reactionHandler.completePrompt(promptEvent.room_id, promptEvent.event_id)); + })()); + } +} diff --git a/src/protections/invitation/WatchRoomsOnInvite.tsx b/src/protections/invitation/WatchRoomsOnInvite.tsx new file mode 100644 index 00000000..2fbbd3c8 --- /dev/null +++ b/src/protections/invitation/WatchRoomsOnInvite.tsx @@ -0,0 +1,129 @@ +// Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. +// Copyright 2022 - 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 +// +// SPDX-FileAttributionText: +// This modified file incorporates work from mjolnir +// https://github.com/matrix-org/mjolnir +// + +import { ALL_RULE_TYPES, Logger, MJOLNIR_SHORTCODE_EVENT_TYPE, MatrixRoomID, MembershipEvent, Ok, Permalink, ProtectedRoomsSet, RoomEvent, RoomStateRevision, Task, Value, isError } from "matrix-protection-suite"; +import { Draupnir } from "../../Draupnir"; +import { DocumentNode } from "../../commands/interface-manager/DeadDocument"; +import { JSXFactory } from "../../commands/interface-manager/JSXFactory"; +import { renderActionResultToEvent, renderMentionPill, renderRoomPill } from "../../commands/interface-manager/MatrixHelpRenderer"; +import { renderMatrixAndSend } from "../../commands/interface-manager/DeadDocumentMatrix"; +import { StaticDecode, Type } from "@sinclair/typebox"; + +const log = new Logger('WatchRoomsOnInvite'); + +const WATCH_LISTS_ON_INVITE_PROMPT_LISTENER = 'me.marewolf.draupnir.watch_rooms_on_invite'; + +// would be nice to be able to use presentation types here idk. +const WatchRoomsOnInvitePromptContext = Type.Object({ + invited_room: Permalink +}); +// this rule is stupid. +// eslint-disable-next-line no-redeclare +type WatchRoomsOnInvitePromptContext = StaticDecode; + +function isRevisionContainingPolicies(revision: RoomStateRevision) { + return revision.getStateEventsOfTypes(ALL_RULE_TYPES).length > 0; +} + +function isRevisionContainingShortcode(revision: RoomStateRevision) { + return revision.getStateEvent(MJOLNIR_SHORTCODE_EVENT_TYPE, '') !== undefined; +} + +export function isRevisionLikelyPolicyRoom(revision: RoomStateRevision) { + return isRevisionContainingPolicies(revision) || isRevisionContainingShortcode(revision); +} + +export class WatchRoomsOnInvite { + private readonly watchPromptListener = this.watchListener.bind(this); + public constructor( + private readonly draupnir: Draupnir, + private readonly protectedRoomsSet: ProtectedRoomsSet, + ) { + this.draupnir.reactionHandler.on(WATCH_LISTS_ON_INVITE_PROMPT_LISTENER, this.watchPromptListener); + } + + handleProtectionDisable(): void { + this.draupnir.reactionHandler.off(WATCH_LISTS_ON_INVITE_PROMPT_LISTENER, this.watchPromptListener); + } + + public promptIfPossiblePolicyRoom(candidateRoom: MatrixRoomID, invitation: MembershipEvent): void { + void Task((async () => { + const stateRevisionIssuer = await this.draupnir.roomStateManager.getRoomStateRevisionIssuer(candidateRoom); + if (isError(stateRevisionIssuer)) { + return stateRevisionIssuer.elaborate(`Unable to fetch the room state revision issuer to check if newly joined room was a policy room.`); + } + if (!isRevisionLikelyPolicyRoom(stateRevisionIssuer.ok.currentRevision)) { + return Ok(undefined); + } + const promptResult = await this.promptWatchPolicyRoom(candidateRoom, invitation); + if (isError(promptResult)) { + return promptResult.elaborate(`Unable to send prompt to ask if Draupnir should watch a policy room`); + } + return Ok(undefined); + })()); + } + + private async promptWatchPolicyRoom(candidateRoom: MatrixRoomID, invitation: MembershipEvent) { + const renderPromptWatch = (): DocumentNode => + + {renderMentionPill(invitation.sender, invitation.sender)} has invited me to a policy room + {renderRoomPill(candidateRoom)}, + would you like Draupnir to watch this room as a policy list? + ; + const reactionMap = new Map(Object.entries({ 'OK': 'OK', 'Cancel': 'Cancel' })); + const promptEventID = (await renderMatrixAndSend( + renderPromptWatch(), + this.draupnir.managementRoomID, + undefined, + this.draupnir.client, + this.draupnir.reactionHandler.createAnnotation( + WATCH_LISTS_ON_INVITE_PROMPT_LISTENER, + reactionMap, + { + invited_room: candidateRoom.toPermalink(), + } + ) + ))[0]; + await this.draupnir.reactionHandler.addReactionsToEvent(this.draupnir.client, this.draupnir.managementRoomID, promptEventID, reactionMap); + return Ok(undefined); + } + + private watchListener(key: string, _item: unknown, rawContext: unknown, _reactionMap: Map, promptEvent: RoomEvent): void { + if (key === 'Cancel') { + void Task(this.draupnir.reactionHandler.cancelPrompt(promptEvent)); + return; + } + if (key !== 'OK') { + return; + } + const context = Value.Decode(WatchRoomsOnInvitePromptContext, rawContext); + if (isError(context)) { + log.error(`Could not decode context from prompt event`, context.error); + renderActionResultToEvent(this.draupnir.client, promptEvent, context); + return; + } + void Task((async () => { + const resolvedRoom = await this.draupnir.clientPlatform.toRoomResolver().resolveRoom(context.ok.invited_room); + if (isError(resolvedRoom)) { + resolvedRoom.elaborate(`Could not resolve the policy room to watch from the MatrixRoomReference: ${context.ok.invited_room.toPermalink()}.`); + renderActionResultToEvent(this.draupnir.client, promptEvent, resolvedRoom); + return; + } + const addResult = await this.protectedRoomsSet.issuerManager.watchList('direct', resolvedRoom.ok, {}); + if (isError(addResult)) { + addResult.elaborate(`Could not watch the policy room: ${resolvedRoom.ok.toPermalink()}`); + renderActionResultToEvent(this.draupnir.client, promptEvent, addResult); + return; + } + renderActionResultToEvent(this.draupnir.client, promptEvent, addResult); + void Task(this.draupnir.reactionHandler.completePrompt(promptEvent.room_id, promptEvent.event_id)); + })()); + } +} diff --git a/src/protections/invitation/inviteCore.ts b/src/protections/invitation/inviteCore.ts new file mode 100644 index 00000000..d6650e81 --- /dev/null +++ b/src/protections/invitation/inviteCore.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { Membership, MembershipEvent, RoomMembershipRevision, StringUserID } from "matrix-protection-suite"; + +export function isInvitationForUser( + event: MembershipEvent, + clientUserID: StringUserID +): event is MembershipEvent & { content: { membership: Membership.Invite }} { + return event.state_key === clientUserID + && event.content.membership === Membership.Invite +}; + +export function isSenderJoinedInRevision( + senderUserID: StringUserID, + membership: RoomMembershipRevision +): boolean { + const senderMembership = membership.membershipForUser(senderUserID); + return Boolean(senderMembership?.content.membership === Membership.Join); +} diff --git a/test/integration/acceptInvitesFromSpaceTest.ts b/test/integration/acceptInvitesFromSpaceTest.ts deleted file mode 100644 index cccc5315..00000000 --- a/test/integration/acceptInvitesFromSpaceTest.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { MatrixClient } from "matrix-bot-sdk"; -import { newTestUser } from "./clientHelper"; -import { DraupnirTestContext, draupnirClient } from "./mjolnirSetupUtils"; - -describe("Test: Accept Invites From Space", function() { - let client: MatrixClient|undefined; - this.beforeEach(async function () { - client = await newTestUser(this.config.homeserverUrl, { name: { contains: "spacee" }}); - await client.start(); - }) - this.afterEach(async function () { - client?.stop(); - }) - it("Mjolnir should accept an invite from a user in a nominated Space", async function(this: DraupnirTestContext) { - this.timeout(20000); - - const draupnir = this.draupnir; - const draupnirSyncClient = draupnirClient(); - if (draupnir === undefined || draupnirSyncClient === null) { - throw new TypeError("fixtures.ts didn't setup Draupnir"); - } - - const space = await client!.createSpace({ - name: "mjolnir space invite test", - invites: [draupnir.clientUserID], - isPublic: false - }); - - await draupnir.client.joinRoom(space.roomId); - - // we're mutating a static object, which may affect other tests :( - draupnir.config.autojoinOnlyIfManager = false; - draupnir.config.acceptInvitesFromSpace = space.roomId; - - const promise = new Promise(async resolve => { - const newRoomId = await client!.createRoom({ invite: [draupnir.clientUserID] }); - client!.on("room.event", (roomId, event) => { - if ( - roomId === newRoomId - && event.type === "m.room.member" - && event.sender === draupnir.clientUserID - && event.content?.membership === "join" - ) { - resolve(null); - } - }); - }); - await promise; - } as unknown as Mocha.AsyncFunc); -}); diff --git a/yarn.lock b/yarn.lock index 7264e8d4..bacf621f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2505,15 +2505,15 @@ matrix-appservice@^2.0.0: request-promise "^4.2.6" sanitize-html "^2.8.0" -"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.20.0": - version "0.20.0" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-0.20.0.tgz#c103fc6f66c14606c669f6e71de8049acf504b2b" - integrity sha512-HA85a64+2TqvgSY2us8wKBb7NoeAoeLBiIMf8btBVCJXppED+oiPxSdwsSy6nZmcWxgNUt1SEvlmhlUCm5EJGg== - -"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@0.20.0": - version "0.20.0" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-0.20.0.tgz#a1ed3a7a2c1ac9bcda870d2c035f165ff2fe376d" - integrity sha512-CHGhyVfmG7Jn8SsxYlvqfFxs5Fyhv0fCP3RVht5pzhErYlvjsdphBRNrkzmq87kNBOJo9kGQ8HWFhxOaU5GCkA== +"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.22.0": + version "0.22.0" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-0.22.0.tgz#71703050b89bf4ddf6462cea2ec26305377210f8" + integrity sha512-mRnYBnffOJxUQNqLnhPaFx9EPPgT2q0ZWkRe7TA1DMhTxsEitk/6c3uQ29vreqvE31fH1U0WqFmrPkQvqRGTww== + +"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@0.22.0": + version "0.22.0" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-0.22.0.tgz#5c4fa1616a7c682b5c21dfcf4a22e281c7b64f7d" + integrity sha512-hqV9wFb2DDWmCJYrJ8tr/lTpQnkfVen+w/YGN15XrcY3M6VtciCEKIUYjp1BWkGUWufKbcYMGN/BRSQsT2PpUw== dependencies: await-lock "^2.2.2" crypto-js "^4.2.0"