Files
Draupnir/apps/draupnir/test/integration/commands/commandUtils.ts
T
2026-05-05 12:31:43 +02:00

298 lines
10 KiB
TypeScript

// Copyright 2022 - 2024 Gnuxie <Gnuxie@protonmail.com>
// Copyright 2021 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: Apache-2.0
//
// SPDX-FileAttributionText: <text>
// This modified file incorporates work from mjolnir
// https://github.com/matrix-org/mjolnir
// </text>
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { strict as assert } from "assert";
import * as crypto from "crypto";
import {
MatrixSendClient,
SafeMatrixEmitter,
} from "matrix-protection-suite-for-matrix-bot-sdk";
import {
NoticeMessageContent,
ReactionEvent,
RoomEvent,
TextMessageContent,
Value,
StringEventIDSchema,
ReactionContent,
hasOwn,
} 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: SafeMatrixEmitter,
targetRoom: string,
targetEventThunk: () => Promise<string>
): Promise<RoomEvent<ReplyContent>> {
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: SafeMatrixEmitter,
targetRoom: string,
n: number,
targetEventThunk: () => Promise<string>
): Promise<RoomEvent<ReplyContent>> {
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<ReplyContent>;
}
}
}
}
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<ReplyContent>);
}
}
}
};
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: SafeMatrixEmitter,
targetRoom: string,
reactionKey: string,
targetEventThunk: () => Promise<string>
): Promise<ReactionEvent> {
const reactionEvents: ReactionEvent[] = [];
const addEvent = function (roomId: string, event: RoomEvent) {
if (roomId !== targetRoom) return;
if (!Value.Check(ReactionEvent, event)) return;
if (Object.keys(event.content).length === 0) return;
reactionEvents.push(event);
};
let targetCb;
try {
matrix.on("room.event", addEvent);
const targetEventId = await targetEventThunk();
for (const event of reactionEvents) {
const relates_to = (
hasOwn(event.content, "m.relates_to")
? event.content["m.relates_to"]
: undefined
) as ReactionContent["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 = (
hasOwn(event.content, "m.relates_to")
? event.content["m.relates_to"]
: undefined
) as ReactionContent["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: SafeMatrixEmitter;
targetRoom: string;
lookAfterEvent: () => Promise</*event id*/ string | undefined>;
predicate: (event: RoomEvent) => boolean;
}): Promise<RoomEvent> {
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 matrix 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,
matrix: SafeMatrixEmitter,
client: MatrixClient
): Promise<string> {
const listName = crypto.randomUUID();
const listCreationResponse = await getFirstReply(
matrix,
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<void> {
// 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);
}