/** * Copyright (C) 2023 Gnuxie * All rights reserved. */ import { EventEmitter } from "stream"; import { MatrixEmitter, MatrixSendClient } from "../../MatrixEmitter"; import { LogService } from "matrix-bot-sdk"; import { trace, traceSync } from "../../utils"; 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, /** * The user id of the client. Ignores reactions from this user */ private readonly clientUserId: string ) { 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. */ @trace('MatrixReactionHandler.handleEvent') 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; } if (event['sender'] === this.clientUserId) { 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. */ @traceSync('MatrixReactionHandler.start') public start(emitter: MatrixEmitter): void { emitter.on('room.event', this.listener); } /** * Stop listening for reactions to events. */ @traceSync('MatrixReactionHandler.stop') 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. */ @traceSync('MatrixReactionHandler.createAnnotation') 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. */ @trace('MatrixReactionHandler.addReactionsToEvent') 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))); } }