From f565b010a40ac68a57578815fb45b28dd2fe56f2 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 24 Mar 2023 17:07:49 +0000 Subject: [PATCH] Add MatrixReactionHandler that allows us to create advanced prompts. These prompts don't have a timeout and don't need a backing store, so this is ideal for the ban propagation protection. --- src/Mjolnir.ts | 6 + .../interface-manager/DeadDocumentMatrix.ts | 3 +- .../MatrixReactionHandler.ts | 129 ++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 src/commands/interface-manager/MatrixReactionHandler.ts diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 1dffd072..9e071b9e 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -49,6 +49,7 @@ import { RoomMemberManager } from "./RoomMembers"; import ProtectedRoomsConfig from "./ProtectedRoomsConfig"; import { MatrixEmitter, MatrixSendClient } from "./MatrixEmitter"; import { findCommandTable } from "./commands/interface-manager/InterfaceCommand"; +import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler"; export const STATE_NOT_STARTED = "not_started"; export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; @@ -96,6 +97,8 @@ export class Mjolnir { private readonly commandTable = findCommandTable("mjolnir"); public readonly policyListManager: PolicyListManager; + public readonly reactionHandler: MatrixReactionHandler; + /** * Adds a listener to the client that will automatically accept invitations. * @param {MatrixSendClient} client @@ -180,6 +183,7 @@ export class Mjolnir { ) { this.protectedRoomsConfig = new ProtectedRoomsConfig(client); this.policyListManager = new PolicyListManager(this); + this.reactionHandler = new MatrixReactionHandler(this.managementRoomId, client); const mutedModules = (LogService as any).mutedModules; if (!Array.isArray(mutedModules)) { @@ -314,6 +318,7 @@ export class Mjolnir { await this.policyListManager.start(); await this.resyncJoinedRooms(false); await this.protectionManager.start(); + this.reactionHandler.start(this.matrixEmitter); if (this.config.verifyPermissionsOnStartup) { await this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions..."); @@ -349,6 +354,7 @@ export class Mjolnir { public stop() { LogService.info("Mjolnir", "Stopping Mjolnir..."); this.matrixEmitter.stop(); + this.reactionHandler.stop(this.matrixEmitter); this.webapis.stop(); this.reportPoller?.stop(); } diff --git a/src/commands/interface-manager/DeadDocumentMatrix.ts b/src/commands/interface-manager/DeadDocumentMatrix.ts index f8c11ff9..89e9ccb7 100644 --- a/src/commands/interface-manager/DeadDocumentMatrix.ts +++ b/src/commands/interface-manager/DeadDocumentMatrix.ts @@ -79,7 +79,7 @@ export async function renderMatrix(node: DocumentNode, cb: SendMatrixEventCB): P * @param event An event to reply to, if any. * @param client A MatrixClient to send the events with. */ -export async function renderMatrixAndSend(node: DocumentNode, roomId: string, event: any|undefined, client: MatrixSendClient): Promise { +export async function renderMatrixAndSend(node: DocumentNode, roomId: string, event: any|undefined, client: MatrixSendClient, additionalContent = {}): Promise { const baseContent = (text: string, html: string) => { return { msgtype: "m.notice", @@ -91,6 +91,7 @@ export async function renderMatrixAndSend(node: DocumentNode, roomId: string, ev const renderInitialReply = async (text: string, html: string) => { return await client.sendMessage(roomId, { ...baseContent(text, html), + ...additionalContent, ...event === undefined ? {} // if they don't supply a reply just send a top level event. : { "m.relates_to": { diff --git a/src/commands/interface-manager/MatrixReactionHandler.ts b/src/commands/interface-manager/MatrixReactionHandler.ts new file mode 100644 index 00000000..5aca186f --- /dev/null +++ b/src/commands/interface-manager/MatrixReactionHandler.ts @@ -0,0 +1,129 @@ +/** + * Copyright (C) 2023 Gnuxie + * All rights reserved. + */ + +import { EventEmitter } from "stream"; +import { MatrixEmitter, MatrixSendClient } from "../../MatrixEmitter"; +import { LogService } from "matrix-bot-sdk"; + +const REACTION_ANNOTATION_KEY = 'ge.applied-langua.ge.draupnir.reaction_handler'; + +type ItemByReactionKey = Map; +export type ReactionListener = (key: string, item: any, additionalContext: unknown, reactionMap: ItemByReactionKey) => 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 { + + private listener: MatrixReactionHandler['handleEvent']; + + public constructor( + /** + * The room the handler is for. Cannot be enabled for every room as the + * OG event lookup is very slow. + */ + public readonly roomId: string, + /** + * A client to lookup the related events to reactions. + */ + private readonly client: MatrixSendClient + ) { + super(); + this.listener = this.handleEvent.bind(this); + } + + /** + * Handle an event from a `MatrixEmitter` and see if it is a reaction to + * a previously annotated event. If it is a reaction to an annotated event, + * then call its associated listener. + * @param roomId The room the event took place in. + * @param event The Matrix event. + */ + private async handleEvent(roomId: string, event: any): Promise { + if (roomId !== this.roomId) { + return; + } + const relatesTo = event['content']?.['m.relates_to']; + if (relatesTo === undefined) { + return; + } + if (relatesTo['rel_type'] !== 'm.annotation') { + return; + } + const reactionKey = relatesTo['key']; + const relatedEventId = relatesTo['event_id']; + if (!(typeof relatedEventId === 'string' && typeof reactionKey === 'string')) { + return; + } + const annotatedEvent = await this.client.getEvent(roomId, relatedEventId); + const annotation = annotatedEvent.content[REACTION_ANNOTATION_KEY]; + if (annotation === undefined) { + return; + } + const reactionMap = annotation['reaction_map']; + if (typeof reactionMap !== 'object' || reactionMap === null) { + LogService.warn('MatrixReactionHandler', `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`); + return; + } + const association = reactionMap[reactionKey]; + if (association === undefined) { + LogService.info('MatrixReactionHandler', `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))); + } + + /** + * Start listening for reactions to events. + * Called normally by an associated mjolnir instance when it is started. + */ + public start(emitter: MatrixEmitter): void { + emitter.on('room.event', this.listener); + } + + /** + * Stop listening for reactions to events. + */ + public stop(emitter: MatrixEmitter): void { + emitter.off('room.event', this.listener); + } + + /** + * Create the annotation required to setup a listener for when a reaction is encountered for the list. + * @param listenerName The name of the event to emit when a reaction is encountered for a matrix event that matches a key in the `reactionMap`. + * @param reactionMap A map of reaction keys to items that will be provided to the listener. + * @param additionalContext Any additional context that should be associated with a matrix event for the listener. + * @returns An object that should be deep copied into a the content of a new Matrix event. + */ + public createAnnotation(listenerName: string, reactionMap: ItemByReactionKey, additionalContext: any = undefined): any { + return { + [REACTION_ANNOTATION_KEY]: { + name: listenerName, + reaction_map: Object.fromEntries(reactionMap), + additional_context: additionalContext, + } + } + } + + /** + * Use a reaction map to create the initial reactions to an event so that the user has access to quick reactions. + * @param client A client to add the reactions with. + * @param roomId The room id of the event to add the reactions to. + * @param eventId The event id of the event to add reactions to. + * @param reactionMap The reaction map. + */ + public async addReactionsToEvent(client: MatrixSendClient, roomId: string, eventId: string, reactionMap: ItemByReactionKey): Promise { + 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))); + } +}