// Copyright 2022 - 2024 Gnuxie // Copyright 2021 The Matrix.org Foundation C.I.C. // // 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 { MatrixClient } from "matrix-bot-sdk"; import { strict as assert } from "assert"; import * as crypto from "crypto"; import { MatrixEmitter, MatrixSendClient, } from "matrix-protection-suite-for-matrix-bot-sdk"; import { NoticeMessageContent, ReactionEvent, RoomEvent, TextMessageContent, Value, StringEventIDSchema, } from "matrix-protection-suite"; import { Type } from "@sinclair/typebox"; import { Draupnir } from "../../../src/Draupnir"; import { StringEventID, StringRoomID, } from "@the-draupnir-project/matrix-basic-types"; export const ReplyContent = Type.Intersect([ Type.Object({ "m.relates_to": Type.Object({ "m.in_reply_to": Type.Object({ event_id: StringEventIDSchema, }), }), }), Type.Union([NoticeMessageContent, TextMessageContent]), ]); export type ReplyContent = typeof ReplyContent; /** * Returns a promise that resolves to the first event replying to the event produced by targetEventThunk. * @param matrix A MatrixEmitter from a MatrixClient that is already in the targetRoom. We will use it to listen for the event produced by targetEventThunk. * This function assumes that the start() has already been called on the client. * @param targetRoom The room to listen for the reply in. * @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reply. * @returns The replying event. */ export async function getFirstReply( matrix: MatrixEmitter, targetRoom: string, targetEventThunk: () => Promise ): Promise> { return getNthReply(matrix, targetRoom, 1, targetEventThunk); } /** * Returns a promise that resolves to the nth event replying to the event produced by targetEventThunk. * @param matrix A MatrixEmitter from a MatrixClient that is already in the targetRoom. We will use it to listen for the event produced by targetEventThunk. * This function assumes that the start() has already been called on the client. * @param targetRoom The room to listen for the reply in. * @param n The number of events to wait for. Must be >= 1. * @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reply. * @returns The replying event. */ export async function getNthReply( matrix: MatrixEmitter, targetRoom: string, n: number, targetEventThunk: () => Promise ): Promise> { if (Number.isNaN(n) || !Number.isInteger(n) || n <= 0) { throw new TypeError(`Invalid number of events ${n}`); } const reactionEvents: RoomEvent[] = []; const addEvent = function (roomId: string, event: RoomEvent) { if (roomId !== targetRoom) return; if (event.type !== "m.room.message") return; reactionEvents.push(event); }; let targetCb; try { matrix.on("room.event", addEvent); const targetEventId = await targetEventThunk(); if (typeof targetEventId !== "string") { throw new TypeError(); } for (const event of reactionEvents) { if (Value.Check(ReplyContent, event.content)) { const in_reply_to = event.content["m.relates_to"]["m.in_reply_to"]; if (in_reply_to.event_id === targetEventId) { n -= 1; if (n === 0) { return event as RoomEvent; } } } } return await new Promise((resolve) => { targetCb = function (roomId: string, event: RoomEvent) { if (roomId !== targetRoom) return; if (event.type !== "m.room.message") return; if (Value.Check(ReplyContent, event.content)) { const in_reply_to = event.content["m.relates_to"]["m.in_reply_to"]; if (in_reply_to.event_id === targetEventId) { n -= 1; if (n === 0) { resolve(event as RoomEvent); } } } }; matrix.on("room.event", targetCb); }); } finally { matrix.removeListener("room.event", addEvent); // the type feedback for eslitn has to be wrong here i don't get it. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (targetCb) { matrix.removeListener("room.event", targetCb); } } } /** * Returns a promise that resolves to an event that is reacting to the event produced by targetEventThunk. * @param matrix A MatrixEmitter for a MatrixClient that is already in the targetRoom that can be started to listen for the event produced by targetEventThunk. * This function assumes that the start() has already been called on the client. * @param targetRoom The room to listen for the reaction in. * @param reactionKey The reaction key to wait for. * @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reaction. * @returns The reaction event. */ export async function getFirstReaction( matrix: MatrixEmitter, targetRoom: string, reactionKey: string, targetEventThunk: () => Promise ): Promise { const reactionEvents: ReactionEvent[] = []; const addEvent = function (roomId: string, event: RoomEvent) { if (roomId !== targetRoom) return; if (!Value.Check(ReactionEvent, event)) return; reactionEvents.push(event); }; let targetCb; try { matrix.on("room.event", addEvent); const targetEventId = await targetEventThunk(); for (const event of reactionEvents) { const relates_to = event.content?.["m.relates_to"]; if ( relates_to?.event_id === targetEventId && relates_to.key === reactionKey ) { return event; } } return await new Promise((resolve) => { targetCb = function (roomId: string, event: RoomEvent) { if (roomId !== targetRoom) return; if (!Value.Check(ReactionEvent, event)) return; const relates_to = event.content["m.relates_to"]; if ( relates_to?.event_id === targetEventId && relates_to.key === reactionKey ) { resolve(event); } }; matrix.on("room.event", targetCb); }); } finally { matrix.off("room.event", addEvent); // idk why the type checker can't detect that this condition is necessary. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (targetCb) { matrix.off("room.event", targetCb); } } } /** * Get and wait for the first event that matches a predicate. * @param details.lookAfterEvent A function that returns an event id to look for * in the sync timeline before matching. Or a function that returns undefined if * `getFirstEventMatching` should start matching events right away. * @returns The event matching the predicate provided. */ export async function getFirstEventMatching(details: { matrix: MatrixEmitter; targetRoom: string; lookAfterEvent: () => Promise; predicate: (event: RoomEvent) => boolean; }): Promise { let targetCb; try { return await new Promise((resolve) => { void details.lookAfterEvent().then((afterEventId: string | undefined) => { // if the event has returned an event id, then we will wait for that in the timeline, // otherwise the "event" isn't a matrix event and we just have to start looking right away. let isAfterEventId = afterEventId === undefined; targetCb = (roomId: string, event: RoomEvent) => { if (event["event_id"] === afterEventId) { isAfterEventId = true; return; } if (isAfterEventId && details.predicate(event)) { resolve(event); } }; details.matrix.on("room.event", targetCb); }); }); } finally { // unless i'm dumb, the type inference for this is wrong, and i don't know why. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (targetCb) { details.matrix.off("room.event", targetCb); } } } /** * Create a new banlist for draupnir to watch and return the shortcode that can be used to refer to the list in future commands. * @param managementRoom The room to send the create command to. * @param mjolnir A syncing matrix client. * @param client A client that isn't draupnir to send the message with, as you will be invited to the room. * @returns The shortcode for the list that can be used to refer to the list in future commands. */ export async function createBanList( managementRoom: string, mjolnir: MatrixEmitter, client: MatrixClient ): Promise { const listName = crypto.randomUUID(); const listCreationResponse = await getFirstReply( mjolnir, managementRoom, async () => { return await client.sendMessage(managementRoom, { msgtype: "m.text", body: `!draupnir list create ${listName} ${listName}`, }); } ); assert.equal( listCreationResponse.content.body.includes( "This list is now being watched." ), true, "could not create a list to test with." ); return listName; } export async function sendCommand( draupnir: Draupnir, command: string ): Promise<{ eventID: StringEventID }> { const eventID = (await draupnir.client.sendMessage( draupnir.managementRoomID, { msgtype: "m.text", body: command, } )) as StringEventID; return { eventID }; } export async function acceptPropmt( client: MatrixSendClient, roomID: StringRoomID, eventID: StringEventID, promptKey: string ): Promise { // i suspect that adding a reaction using the unstable API doesn't work because it uses the usntable prefix // whereas our schema doesn't have the unstable event type. // we don't test for this anywhere and we should really unify the situation between draupnir, draupnir safe mode, // and any new bots that just need all the same things. await client.unstableApis.addReactionToEvent(roomID, eventID, promptKey); }