From fdf51eb66619cbe70f3c290361446d0f1a4b4205 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Tue, 21 Feb 2023 19:37:27 +0000 Subject: [PATCH 1/8] Experimental Protection to propagate room level bans to policies. - Needs an automated option - I really want this to be enabled by default - It needs to be easily configurable and very visible because it's a really useful feature. - Need to check that they are not already banned on a policy list. - Allow possibility to rely last message like a report behind spoiler text. --- .../interface-manager/DeadDocumentMatrix.ts | 13 ++- .../interface-manager/MatrixHelpRenderer.tsx | 5 + src/protections/BanPropagation.tsx | 109 ++++++++++++++++++ src/protections/ProtectionManager.ts | 2 + 4 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 src/protections/BanPropagation.tsx diff --git a/src/commands/interface-manager/DeadDocumentMatrix.ts b/src/commands/interface-manager/DeadDocumentMatrix.ts index a2c7e494..529a64e1 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. * @param client A MatrixClient to send the events with. */ -export async function renderMatrixAndSend(node: DocumentNode, roomId: string, event: any, client: MatrixSendClient): Promise { +export async function renderMatrixAndSend(node: DocumentNode, roomId: string, event: any|undefined, client: MatrixSendClient): Promise { const baseContent = (text: string, html: string) => { return { msgtype: "m.notice", @@ -91,11 +91,14 @@ export async function renderMatrixAndSend(node: DocumentNode, roomId: string, ev const renderInitialReply = async (text: string, html: string) => { return await client.sendMessage(roomId, { ...baseContent(text, html), - "m.relates_to": { - "m.in_reply_to": { - "event_id": event['event_id'] + ...event === undefined + ? {} + : { "m.relates_to": { + "m.in_reply_to": { + "event_id": event['event_id'] + } + } } - } }) }; const renderThreadReply = async (eventId: string, text: string, html: string) => { diff --git a/src/commands/interface-manager/MatrixHelpRenderer.tsx b/src/commands/interface-manager/MatrixHelpRenderer.tsx index 49be155c..93743232 100644 --- a/src/commands/interface-manager/MatrixHelpRenderer.tsx +++ b/src/commands/interface-manager/MatrixHelpRenderer.tsx @@ -105,4 +105,9 @@ function renderCommandException(command: InterfaceCommand, error: Details can be found by providing the reference {error.uuid} to an administrator. +} + +export function renderMentionPill(mxid: string, displayName: string): DocumentNode { + const url = `https://matrix.to/#/${mxid}`; + return {displayName} } \ No newline at end of file diff --git a/src/protections/BanPropagation.tsx b/src/protections/BanPropagation.tsx new file mode 100644 index 00000000..23b5d28d --- /dev/null +++ b/src/protections/BanPropagation.tsx @@ -0,0 +1,109 @@ +/** + * Copyright (C) 2022-2023 Gnuxie + * All rights reserved. + * + * This file is modified and is NOT licensed under the Apache License. + * This modified file incorperates work from mjolnir + * https://github.com/matrix-org/mjolnir + * which included the following license notice: + +Copyright 2019-2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + * + * However, this file is modified and the modifications in this file + * are NOT distributed, contributed, committed, or licensed under the Apache License. + */ + +import { Protection } from "./IProtection"; +import { Mjolnir } from "../Mjolnir"; +import { LogService } from "matrix-bot-sdk"; +import { PromptResponseListener } from "../commands/interface-manager/MatrixPromptUX"; +import { JSXFactory } from "../commands/interface-manager/JSXFactory"; +import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentMatrix"; +import { renderMentionPill } from "../commands/interface-manager/MatrixHelpRenderer"; +import { RULE_USER } from "../models/ListRule"; + +/** + * This could not be implemented as a protection for the following reasons: + * - To enable a protection by default would require a significant refactor + * - For some reason bans aren't showing in the protection manager via room.event? + */ + +/** + * Prompt the management room to propagate a user ban to a policy list of their choice. + * @param mjolnir Mjolnir. + * @param event The ban event. + * @param roomId The room that the ban happened in. + * @returns An event id which can be used by the `PromptResponseListener`. + */ +async function promptBanPropagation( + mjolnir: Mjolnir, + event: any, + roomId: string +): Promise { + return (await renderMatrixAndSend( + The user {renderMentionPill(event["state_key"], event["content"]?.["displayname"] ?? event["state_key"])} was banned + in {roomId} for {event["content"]?.["reason"] ?? ''}.
+ Would you like to add the ban to a policy list? +
    + {mjolnir.policyListManager.lists.map(list =>
  1. {list}
  2. )} +
+
, + mjolnir.managementRoomId, + undefined, + mjolnir.client + )).at(0) as string; +} + +export class BanPropagation extends Protection { + + settings = {}; + + public get name(): string { + return 'BanPropagationProtection'; + } + public get description(): string { + return "When you ban a user in any protected room with a client, this protection\ + will turn the room level ban into a policy for a policy list of your choice.\ + This will then allow the bot to ban the user from all of your rooms."; + } + + // TODO: for the automated version we should check that the sender is in the management room. + // TODO: I really want this to be enabled by default. + public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { + if (event['type'] === 'm.room.member' && event['content']?.['membership'] === 'ban') { + const promptListener = new PromptResponseListener( + mjolnir.matrixEmitter, + await mjolnir.client.getUserId(), + mjolnir.client + ); + // do not await, we don't want to block the protection manager + promptListener.waitForPresentationList( + mjolnir.policyListManager.lists, + mjolnir.managementRoomId, + promptBanPropagation(mjolnir, event, roomId) + ).then(listResult => { + if (listResult.isOk()) { + const list = listResult.ok; + return list.banEntity(RULE_USER, event['state_key'], event['content']?.["reason"]); + } else { + LogService.warn("BanPropagation", "Timed out waiting for a response to a room level ban", listResult.err); + return; + } + }).catch(e => { + LogService.error('BanPropagation', "Encountered an error while prompting the user for instructions to propagate a room level ban", e); + }); + } + } +} diff --git a/src/protections/ProtectionManager.ts b/src/protections/ProtectionManager.ts index 866d5e30..e15063d6 100644 --- a/src/protections/ProtectionManager.ts +++ b/src/protections/ProtectionManager.ts @@ -41,9 +41,11 @@ import { Consequence } from "./consequence"; import { htmlEscape } from "../utils"; import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache"; import { RoomUpdateError } from "../models/RoomUpdateError"; +import { BanPropagation } from "./BanPropagation"; const PROTECTIONS: Protection[] = [ new FirstMessageIsImage(), + new BanPropagation(), new BasicFlooding(), new WordList(), new MessageIsVoice(), From 6e49605c1febb9d4d416ef91eaab0784af344679 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 22 Feb 2023 12:15:10 +0000 Subject: [PATCH 2/8] Use MatrixDataManager for enabled protections. This will allow us to create "enabled by default" protections via a schema migration. --- src/protections/ProtectionManager.ts | 92 +++++++++++++++++++++------- 1 file changed, 71 insertions(+), 21 deletions(-) diff --git a/src/protections/ProtectionManager.ts b/src/protections/ProtectionManager.ts index e15063d6..8baefe54 100644 --- a/src/protections/ProtectionManager.ts +++ b/src/protections/ProtectionManager.ts @@ -42,6 +42,7 @@ import { htmlEscape } from "../utils"; import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache"; import { RoomUpdateError } from "../models/RoomUpdateError"; import { BanPropagation } from "./BanPropagation"; +import { MatrixDataManager, RawSchemedData, SCHEMA_VERSION_KEY } from "../models/MatrixDataManager"; const PROTECTIONS: Protection[] = [ new FirstMessageIsImage(), @@ -56,19 +57,84 @@ const PROTECTIONS: Protection[] = [ ]; const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections"; +type EnabledProtectionsEvent = RawSchemedData & { + enabled: string[], +} + +class EnabledProtectionsManager extends MatrixDataManager { + protected readonly schema = []; + protected readonly isAllowedToInferNoVersionAsZero = true; + private readonly enabledProtections = new Set(); + + constructor( + private readonly mjolnir: Mjolnir + ) { + super() + } + + protected async requestMatrixData(): Promise { + try { + return await this.mjolnir.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE); + } catch (e) { + if (e.statusCode === 404) { + LogService.warn('PolicyListManager', "Couldn't find account data for Draupnir's protections, assuming first start.", e); + return this.createFirstData(); + } else { + throw e; + } + } + } + + protected async storeMatixData(): Promise { + const data: EnabledProtectionsEvent = { + enabled: [...this.enabledProtections], + [SCHEMA_VERSION_KEY]: 0, + } + await this.mjolnir.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, data); + } + + protected async createFirstData(): Promise { + return { enabled: [], [SCHEMA_VERSION_KEY]: 0 }; + } + + public isEnabled(protection: Protection): boolean { + return this.enabledProtections.has(protection.name); + } + + public async enable(protection: Protection): Promise { + this.enabledProtections.add(protection.name); + protection.enabled = true; + await this.storeMatixData(); + } + + public async disable(protection: Protection): Promise { + this.enabledProtections.delete(protection.name); + protection.enabled = false; + await this.storeMatixData(); + } + + public async start(): Promise { + const data = await this.loadData(); + for (const protection of data.enabled) { + this.enabledProtections.add(protection); + } + } +} + const CONSEQUENCE_EVENT_DATA = "org.matrix.mjolnir.consequence"; /** * This is responsible for informing protections about relevant events and handle standard consequences. */ export class ProtectionManager { + private enabledProtectionsManager: EnabledProtectionsManager; private _protections = new Map(); get protections(): Readonly> { return this._protections; } - constructor(private readonly mjolnir: Mjolnir) { + this.enabledProtectionsManager = new EnabledProtectionsManager(this.mjolnir); } /* @@ -76,6 +142,7 @@ export class ProtectionManager { * update their settings with any saved non-default values */ public async start() { + await this.enabledProtectionsManager.start(); this.mjolnir.reportManager.on("report.new", this.handleReport.bind(this)); this.mjolnir.matrixEmitter.on("room.event", this.handleEvent.bind(this)); for (const protection of PROTECTIONS) { @@ -99,15 +166,7 @@ export class ProtectionManager { */ public async registerProtection(protection: Protection) { this._protections.set(protection.name, protection) - - let enabledProtections: { enabled: string[] } | null = null; - try { - enabledProtections = await this.mjolnir.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE); - } catch { - // this setting either doesn't exist, or we failed to read it (bad network?) - // TODO: retry on certain failures? - } - protection.enabled = enabledProtections?.enabled.includes(protection.name) ?? false; + protection.enabled = this.enabledProtectionsManager.isEnabled(protection) ?? false; const savedSettings = await this.getProtectionSettings(protection.name); for (let [key, value] of Object.entries(savedSettings)) { @@ -162,13 +221,6 @@ export class ProtectionManager { ); } - /* - * Make a list of the names of enabled protections and save them in a state event - */ - private async saveEnabledProtections() { - const protections = this.enabledProtections.map(p => p.name); - await this.mjolnir.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: protections }); - } /* * Enable a protection by name and persist its enable state in to a state event * @@ -177,8 +229,7 @@ export class ProtectionManager { public async enableProtection(name: string) { const protection = this._protections.get(name); if (protection !== undefined) { - protection.enabled = true; - await this.saveEnabledProtections(); + await this.enabledProtectionsManager.enable(protection); } } @@ -205,8 +256,7 @@ export class ProtectionManager { public async disableProtection(name: string) { const protection = this._protections.get(name); if (protection !== undefined) { - protection.enabled = false; - await this.saveEnabledProtections(); + await this.enabledProtectionsManager.disable(protection); } } From f1ede5eba6bff0f2e4a391325cc759fd37989a2b Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 22 Feb 2023 12:28:07 +0000 Subject: [PATCH 3/8] Enable BanPropagationProtection by default --- src/protections/ProtectionManager.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/protections/ProtectionManager.ts b/src/protections/ProtectionManager.ts index 8baefe54..79f0f499 100644 --- a/src/protections/ProtectionManager.ts +++ b/src/protections/ProtectionManager.ts @@ -42,7 +42,7 @@ import { htmlEscape } from "../utils"; import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache"; import { RoomUpdateError } from "../models/RoomUpdateError"; import { BanPropagation } from "./BanPropagation"; -import { MatrixDataManager, RawSchemedData, SCHEMA_VERSION_KEY } from "../models/MatrixDataManager"; +import { MatrixDataManager, RawSchemedData, SchemaMigration, SCHEMA_VERSION_KEY } from "../models/MatrixDataManager"; const PROTECTIONS: Protection[] = [ new FirstMessageIsImage(), @@ -62,7 +62,20 @@ type EnabledProtectionsEvent = RawSchemedData & { } class EnabledProtectionsManager extends MatrixDataManager { - protected readonly schema = []; + protected readonly schema: SchemaMigration[] = [ + async function enableBanPropagationByDefault(input: EnabledProtectionsEvent) { + const enabled = new Set(input.enabled); + const banPropagationProtection = PROTECTIONS.find(p => p.name === 'BanPropagationProtection'); + if (banPropagationProtection === undefined) { + throw new TypeError("Couldn't find the ban propagation protection"); + } + enabled.add(banPropagationProtection.name) + return { + enabled: [...enabled], + [SCHEMA_VERSION_KEY]: 1, + } + } + ]; protected readonly isAllowedToInferNoVersionAsZero = true; private readonly enabledProtections = new Set(); @@ -88,7 +101,7 @@ class EnabledProtectionsManager extends MatrixDataManager { const data: EnabledProtectionsEvent = { enabled: [...this.enabledProtections], - [SCHEMA_VERSION_KEY]: 0, + [SCHEMA_VERSION_KEY]: 1, } await this.mjolnir.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, data); } From 88f264bff32529da14ae6ba016f2b4aacad1aa5a Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 22 Feb 2023 14:44:05 +0000 Subject: [PATCH 4/8] BanPropagation: only prompt when user is not already banned. --- src/protections/BanPropagation.tsx | 59 +++++++++++++++--------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/src/protections/BanPropagation.tsx b/src/protections/BanPropagation.tsx index 23b5d28d..8cf5dba5 100644 --- a/src/protections/BanPropagation.tsx +++ b/src/protections/BanPropagation.tsx @@ -34,12 +34,6 @@ import { renderMatrixAndSend } from "../commands/interface-manager/DeadDocumentM import { renderMentionPill } from "../commands/interface-manager/MatrixHelpRenderer"; import { RULE_USER } from "../models/ListRule"; -/** - * This could not be implemented as a protection for the following reasons: - * - To enable a protection by default would require a significant refactor - * - For some reason bans aren't showing in the protection manager via room.event? - */ - /** * Prompt the management room to propagate a user ban to a policy list of their choice. * @param mjolnir Mjolnir. @@ -79,31 +73,36 @@ export class BanPropagation extends Protection { This will then allow the bot to ban the user from all of your rooms."; } - // TODO: for the automated version we should check that the sender is in the management room. - // TODO: I really want this to be enabled by default. public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { - if (event['type'] === 'm.room.member' && event['content']?.['membership'] === 'ban') { - const promptListener = new PromptResponseListener( - mjolnir.matrixEmitter, - await mjolnir.client.getUserId(), - mjolnir.client - ); - // do not await, we don't want to block the protection manager - promptListener.waitForPresentationList( - mjolnir.policyListManager.lists, - mjolnir.managementRoomId, - promptBanPropagation(mjolnir, event, roomId) - ).then(listResult => { - if (listResult.isOk()) { - const list = listResult.ok; - return list.banEntity(RULE_USER, event['state_key'], event['content']?.["reason"]); - } else { - LogService.warn("BanPropagation", "Timed out waiting for a response to a room level ban", listResult.err); - return; - } - }).catch(e => { - LogService.error('BanPropagation', "Encountered an error while prompting the user for instructions to propagate a room level ban", e); - }); + if (event['type'] !== 'm.room.member' || event['content']?.['membership'] !== 'ban') { + return; + } + if (mjolnir.policyListManager.lists.map( + list => list.rulesMatchingEntity(event['state_key'], RULE_USER) + ).some(rules => rules.length > 0) + ) { + return; // The user is already banned. } + const promptListener = new PromptResponseListener( + mjolnir.matrixEmitter, + await mjolnir.client.getUserId(), + mjolnir.client + ); + // do not await, we don't want to block the protection manager + promptListener.waitForPresentationList( + mjolnir.policyListManager.lists, + mjolnir.managementRoomId, + promptBanPropagation(mjolnir, event, roomId) + ).then(listResult => { + if (listResult.isOk()) { + const list = listResult.ok; + return list.banEntity(RULE_USER, event['state_key'], event['content']?.["reason"]); + } else { + LogService.warn("BanPropagation", "Timed out waiting for a response to a room level ban", listResult.err); + return; + } + }).catch(e => { + LogService.error('BanPropagation', "Encountered an error while prompting the user for instructions to propagate a room level ban", e); + }); } } From 73b15c30d2ba3f4f72a99477889a88bd216a841b Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 22 Feb 2023 14:44:34 +0000 Subject: [PATCH 5/8] Test for BanPropagationProtection. --- test/integration/banPropagationTest.ts | 58 +++++++++++++++++++++++ test/integration/commands/commandUtils.ts | 25 ++++++++++ test/integration/roomMembersTest.ts | 1 + 3 files changed, 84 insertions(+) create mode 100644 test/integration/banPropagationTest.ts diff --git a/test/integration/banPropagationTest.ts b/test/integration/banPropagationTest.ts new file mode 100644 index 00000000..73b6c995 --- /dev/null +++ b/test/integration/banPropagationTest.ts @@ -0,0 +1,58 @@ +import expect from "expect"; +import { Mjolnir } from "../../src/Mjolnir"; +import { newTestUser } from "./clientHelper"; +import { getFirstEventMatching } from './commands/commandUtils'; +import { Permalinks } from "matrix-bot-sdk"; +import { RULE_USER } from "../../src/models/ListRule"; + +// We will need to disable this in tests that are banning people otherwise it will cause +// mocha to hang for awhile until it times out waiting for a response to a prompt. +describe("Ban propagation test", function() { + it("Should be enabled by default", async function() { + const mjolnir: Mjolnir = this.mjolnir + expect(mjolnir.protectionManager.getProtection("BanPropagationProtection")?.enabled).toBeTruthy(); + }) + it("Should prompt to add bans to a policy list, then add the ban", async function() { + const mjolnir: Mjolnir = this.mjolnir + const mjolnirId = await mjolnir.client.getUserId(); + const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); + await moderator.joinRoom(mjolnir.managementRoomId); + const protectedRooms = await Promise.all([...Array(5)].map(async _ => { + const room = await moderator.createRoom({ invite: [mjolnirId] }); + await mjolnir.client.joinRoom(room); + await moderator.setUserPowerLevel(mjolnirId, room, 100); + await mjolnir.addProtectedRoom(room); + return room; + })); + // create a policy list so that we can check it for a user rule later + const policyListId = await moderator.createRoom({ invite: [mjolnirId] }); + await moderator.setUserPowerLevel(mjolnirId, policyListId, 100); + await mjolnir.client.joinRoom(policyListId); + await mjolnir.policyListManager.watchList(Permalinks.forRoom(policyListId)); + + // check for the prompt + const promptEvent = await getFirstEventMatching({ + matrix: mjolnir.matrixEmitter, + targetRoom: mjolnir.managementRoomId, + lookAfterEvent: async function () { + // ban a user in one of our protected rooms using the moderator + await moderator.banUser('@test:example.com', protectedRooms[0], "spam"); + return undefined; + }, + predicate: function (event: any): boolean { + return (event['content']?.['body'] ?? '').startsWith('The user') + } + }) + // select the prompt + await moderator.unstableApis.addReactionToEvent( + mjolnir.managementRoomId, promptEvent['event_id'], '1.' + ); + // check the policy list, after waiting a few seconds. + await new Promise(resolve => setTimeout(resolve, 10000)); + const policyList = mjolnir.policyListManager.lists[0]; + const rules = policyList.rulesMatchingEntity('@test:example.com', RULE_USER); + expect(rules.length).toBe(1); + expect(rules[0].entity).toBe('@test:example.com'); + expect(rules[0].reason).toBe('spam'); + }) +}) diff --git a/test/integration/commands/commandUtils.ts b/test/integration/commands/commandUtils.ts index 41aff537..9feef330 100644 --- a/test/integration/commands/commandUtils.ts +++ b/test/integration/commands/commandUtils.ts @@ -118,6 +118,31 @@ export async function getFirstReaction(matrix: MatrixEmitter, targetRoom: string } } +export async function getFirstEventMatching(details: { matrix: MatrixEmitter, targetRoom: string, lookAfterEvent: () => Promise, predicate: (event: any) => boolean }): Promise { + let targetCb = undefined; + try { + return await new Promise((resolve, reject) => { + details.lookAfterEvent().then((afterEventId: string|undefined) => { + let isAfterEventId = afterEventId === undefined; + targetCb = (roomId: string, event: any) => { + if (event['event_id'] === afterEventId) { + isAfterEventId = true; + return; + } + if (isAfterEventId && details.predicate(event)) { + resolve(event); + } + }; + details.matrix.on('room.event', targetCb) + }) + }) + } finally { + if (targetCb) { + details.matrix.off('room.event', targetCb) + } + } +} + /** * Create a new banlist for mjolnir 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. diff --git a/test/integration/roomMembersTest.ts b/test/integration/roomMembersTest.ts index 3ad23cc2..498f3c33 100644 --- a/test/integration/roomMembersTest.ts +++ b/test/integration/roomMembersTest.ts @@ -257,6 +257,7 @@ describe("Test: Testing RoomMemberManager", function() { this.timeout(60000); const start = new Date(Date.now() - 10_000); const mjolnir: Mjolnir = this.mjolnir!; + mjolnir.protectionManager.disableProtection("BanPropagationProtection"); // don't respond to room level bans. // Setup a moderator. this.moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); From d078c9b14c3203db3565655e0ed4b6124d43c9e9 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 22 Feb 2023 15:29:53 +0000 Subject: [PATCH 6/8] clearTimeout for prompt reactions if we got a reaction. --- src/commands/interface-manager/MatrixPromptUX.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/interface-manager/MatrixPromptUX.ts b/src/commands/interface-manager/MatrixPromptUX.ts index fb6d3b56..8886932d 100644 --- a/src/commands/interface-manager/MatrixPromptUX.ts +++ b/src/commands/interface-manager/MatrixPromptUX.ts @@ -106,14 +106,16 @@ class ReactionHandler { roomId: string, eventId: string, presentationByReaction: PresentationByReactionKey, timeout = 600_000 // ten minutes ): Promise> { let record; + let timeoutId; const presentationOrTimeout = await Promise.race([ new Promise(resolve => { record = new ReactionPromptRecord(presentationByReaction, resolve); this.addPresentationsForEvent(eventId, record); this.addBaseReactionsToEvent(roomId, eventId, presentationByReaction); }), - new Promise(resolve => setTimeout(resolve, timeout)), + new Promise(resolve => timeoutId = setTimeout(resolve, timeout)), ]); + clearTimeout(timeoutId); if (presentationOrTimeout === undefined) { if (record !== undefined) { this.removePromptRecordForEvent(eventId, record); From 948eec1261a72e7cf7284803351611f9b6499e73 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 22 Feb 2023 16:28:16 +0000 Subject: [PATCH 7/8] Allow renderMatrixAndSend to not need a reply. --- src/commands/interface-manager/DeadDocumentMatrix.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/interface-manager/DeadDocumentMatrix.ts b/src/commands/interface-manager/DeadDocumentMatrix.ts index 529a64e1..f8c11ff9 100644 --- a/src/commands/interface-manager/DeadDocumentMatrix.ts +++ b/src/commands/interface-manager/DeadDocumentMatrix.ts @@ -76,7 +76,7 @@ export async function renderMatrix(node: DocumentNode, cb: SendMatrixEventCB): P * Render the document node to html+text `m.notice` events. * @param node The document node to render. * @param roomId The room to send the events to. - * @param event An event to reply to. + * @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 { @@ -92,7 +92,7 @@ export async function renderMatrixAndSend(node: DocumentNode, roomId: string, ev return await client.sendMessage(roomId, { ...baseContent(text, html), ...event === undefined - ? {} + ? {} // if they don't supply a reply just send a top level event. : { "m.relates_to": { "m.in_reply_to": { "event_id": event['event_id'] From 1d62ec2f9acfe9a410cec750ffc684e954e9933e Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 22 Feb 2023 16:37:01 +0000 Subject: [PATCH 8/8] document getFirstEventMatching --- test/integration/commands/commandUtils.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/integration/commands/commandUtils.ts b/test/integration/commands/commandUtils.ts index 9feef330..e1a98556 100644 --- a/test/integration/commands/commandUtils.ts +++ b/test/integration/commands/commandUtils.ts @@ -118,11 +118,20 @@ export async function getFirstReaction(matrix: MatrixEmitter, targetRoom: string } } +/** + * 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: any) => boolean }): Promise { - let targetCb = undefined; + let targetCb; try { return await new Promise((resolve, reject) => { 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: any) => { if (event['event_id'] === afterEventId) {