From 0f91bce6927be91be47646d92fb61311a5813cbd Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Fri, 25 Oct 2024 17:10:29 +0100 Subject: [PATCH] Notify moderation room when users in protected rooms mention the bot (configurable) (#553) * Add support for forwarding mentions of the moderator bot to the moderation room. * Add a test * Cleanup a few imports from other tests. * Add licence + test for spam. * Fix test --- config/default.yaml | 4 + src/Mjolnir.ts | 39 ++++++- src/config.ts | 2 + test/integration/forwardedMentionsTest.ts | 117 +++++++++++++++++++ test/integration/protectedRoomsConfigTest.ts | 3 +- test/integration/standardConsequenceTest.ts | 5 +- 6 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 test/integration/forwardedMentionsTest.ts diff --git a/config/default.yaml b/config/default.yaml index e8a81643..610bc717 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -65,6 +65,10 @@ recordIgnoredInvites: false # (see verboseLogging to adjust this a bit.) managementRoom: "#moderators:example.org" +# Forward any messages mentioning the bot user to the mangement room. Repeated mentions within +# a 10 minute period are ignored. +forwardMentionsToManagementRoom: false + # Whether Mjolnir should log a lot more messages in the room, # mainly involves "all-OK" messages, and debugging messages for when mjolnir checks bans in a room. verboseLogging: true diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index cf59fc80..8efc05ee 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -1,5 +1,5 @@ /* -Copyright 2019-2021 The Matrix.org Foundation C.I.C. +Copyright 2019-2024 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. @@ -14,7 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { extractRequestError, LogLevel, LogService, MembershipEvent } from "@vector-im/matrix-bot-sdk"; +import { + extractRequestError, + LogLevel, + LogService, + MembershipEvent, + Permalinks, + UserID, +} from "@vector-im/matrix-bot-sdk"; import { ALL_RULE_TYPES as ALL_BAN_LIST_RULE_TYPES } from "./models/ListRule"; import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler"; @@ -34,6 +41,7 @@ import { RoomMemberManager } from "./RoomMembers"; import ProtectedRoomsConfig from "./ProtectedRoomsConfig"; import { MatrixEmitter, MatrixSendClient } from "./MatrixEmitter"; import { OpenMetrics } from "./webapis/OpenMetrics"; +import { LRUCache } from "lru-cache"; import { ModCache } from "./ModCache"; export const STATE_NOT_STARTED = "not_started"; @@ -82,6 +90,11 @@ export class Mjolnir { public readonly policyListManager: PolicyListManager; + public readonly lastBotMentionForRoomId = new LRUCache({ + ttl: 1000 * 60 * 8, // 8 minutes + ttlAutopurge: true, + }); + /** * Members of the moderator room and others who should not be banned, ACL'd etc. */ @@ -210,9 +223,29 @@ export class Mjolnir { matrixEmitter.on("room.message", async (roomId, event) => { const eventContent = event.content; - if (roomId !== this.managementRoomId) return; if (typeof eventContent !== "object") return; + if (this.config.forwardMentionsToManagementRoom && this.protectedRoomsTracker.isProtectedRoom(roomId)) { + if (eventContent?.["m.mentions"]?.user_ids?.includes(this.clientUserId)) { + LogService.info("Mjolnir", `Bot mentioned ${roomId} by ${event.sender}`); + // Bot mentioned in a public room. + if (this.lastBotMentionForRoomId.has(roomId)) { + // Mentioned too recently, ignore. + return; + } + this.lastBotMentionForRoomId.set(roomId, true); + const permalink = Permalinks.forEvent(roomId, event.event_id, [ + new UserID(this.clientUserId).domain, + ]); + await this.managementRoomOutput.logMessage( + LogLevel.INFO, + "Mjolnir", + `Bot mentioned ${roomId} by ${event.sender} in ${permalink}`, + roomId, + ); + } + } + const { msgtype, body: originalBody, sender, event_id } = eventContent; if (msgtype !== "m.text" || typeof originalBody !== "string") { return; diff --git a/src/config.ts b/src/config.ts index 38057a10..8b965ecf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -90,6 +90,7 @@ export interface IConfig { acceptInvitesFromSpace: string; recordIgnoredInvites: boolean; managementRoom: string; + forwardMentionsToManagementRoom: boolean; verboseLogging: boolean; logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR"; syncOnStartup: boolean; @@ -209,6 +210,7 @@ const defaultConfig: IConfig = { autojoinOnlyIfManager: true, recordIgnoredInvites: false, managementRoom: "!noop:example.org", + forwardMentionsToManagementRoom: false, verboseLogging: false, logLevel: "INFO", syncOnStartup: true, diff --git a/test/integration/forwardedMentionsTest.ts b/test/integration/forwardedMentionsTest.ts new file mode 100644 index 00000000..caa1eec1 --- /dev/null +++ b/test/integration/forwardedMentionsTest.ts @@ -0,0 +1,117 @@ +/* +Copyright 2024 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. +*/ + +import { MatrixClient, UserID } from "@vector-im/matrix-bot-sdk"; +import { Mjolnir } from "../../src/Mjolnir"; +import { newTestUser, noticeListener } from "./clientHelper"; +import { strict as assert } from "assert"; +import expect from "expect"; + +describe("Test: config.forwardMentionsToManagementRoom behaves correctly.", function () { + let moderator: MatrixClient; + this.beforeEach(async function () { + moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); + await moderator.start(); + }); + + this.afterEach(async function () { + moderator.stop(); + }); + + it("correctly forwards a mention.", async function () { + const mjolnir: Mjolnir = this.mjolnir!; + const botUserId = await mjolnir.client.getUserId(); + mjolnir.config.forwardMentionsToManagementRoom = true; + + const mentioninguser = await newTestUser(this.config.homeserverUrl, { name: { contains: "mentioninguser" } }); + const mentioningUserId = await mentioninguser.getUserId(); + await moderator.joinRoom(mjolnir.managementRoomId); + const protectedRoom = await moderator.createRoom({ preset: "public_chat" }); + await mjolnir.client.joinRoom(protectedRoom); + await mentioninguser.joinRoom(protectedRoom); + await mjolnir.protectedRoomsTracker.addProtectedRoom(protectedRoom); + + await moderator.start(); + const noticeBody = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Timed out waiting for notice")), 8000); + moderator.on( + "room.message", + noticeListener(this.mjolnir.managementRoomId, (event) => { + if (event.content.body.includes(`Bot mentioned`)) { + clearTimeout(timeout); + resolve(event.content.body); + } + }), + ); + }); + + const mentionEventId = await mentioninguser.sendMessage(protectedRoom, { + msgtype: "m.text", + body: "Moderator: Testing this", + ["m.mentions"]: { + user_ids: [botUserId], + }, + }); + const domain = new UserID(mentioningUserId).domain; + + assert.equal( + await noticeBody, + `Bot mentioned ${protectedRoom} by ${mentioningUserId} in https://matrix.to/#/${protectedRoom}/${mentionEventId}?via=${domain}`, + "Forwarded mention format mismatch", + ); + }); + + it("only forwards the first mention from a user.", async function () { + const mjolnir: Mjolnir = this.mjolnir!; + const botUserId = await mjolnir.client.getUserId(); + mjolnir.config.forwardMentionsToManagementRoom = true; + + const mentioninguser = await newTestUser(this.config.homeserverUrl, { name: { contains: "mentioninguser" } }); + await moderator.joinRoom(mjolnir.managementRoomId); + const protectedRoom = await moderator.createRoom({ preset: "public_chat" }); + await mjolnir.client.joinRoom(protectedRoom); + await mentioninguser.joinRoom(protectedRoom); + await mjolnir.protectedRoomsTracker.addProtectedRoom(protectedRoom); + + await moderator.start(); + let mentions = new Set(); + moderator.on( + "room.message", + noticeListener(this.mjolnir.managementRoomId, (event) => { + if (event.content.body.includes(`Bot mentioned`)) { + mentions.add(event.event_id); + } + }), + ); + + function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + for (let index = 0; index < 5; index++) { + await mentioninguser.sendMessage(protectedRoom, { + msgtype: "m.text", + body: `Moderator: Testing this ${index}`, + ["m.mentions"]: { + user_ids: [botUserId], + }, + }); + await delay(2000); + } + + expect(mentions.size).toBe(1); + }); +}); diff --git a/test/integration/protectedRoomsConfigTest.ts b/test/integration/protectedRoomsConfigTest.ts index 82694205..578bdfe9 100644 --- a/test/integration/protectedRoomsConfigTest.ts +++ b/test/integration/protectedRoomsConfigTest.ts @@ -4,10 +4,9 @@ import { MatrixSendClient } from "../../src/MatrixEmitter"; import { Mjolnir } from "../../src/Mjolnir"; import PolicyList from "../../src/models/PolicyList"; import { newTestUser } from "./clientHelper"; -import { createBanList, getFirstReaction } from "./commands/commandUtils"; +import { createBanList } from "./commands/commandUtils"; async function createPolicyList(client: MatrixClient): Promise { - const serverName = new UserID(await client.getUserId()).domain; const policyListId = await client.createRoom({ preset: "public_chat" }); return new PolicyList(policyListId, Permalinks.forRoom(policyListId), client); } diff --git a/test/integration/standardConsequenceTest.ts b/test/integration/standardConsequenceTest.ts index b83b3be8..4d79d8b5 100644 --- a/test/integration/standardConsequenceTest.ts +++ b/test/integration/standardConsequenceTest.ts @@ -1,9 +1,6 @@ -import { strict as assert } from "assert"; - import { Mjolnir } from "../../src/Mjolnir"; import { Protection } from "../../src/protections/IProtection"; -import { newTestUser, noticeListener } from "./clientHelper"; -import { matrixClient, mjolnir } from "./mjolnirSetupUtils"; +import { newTestUser } from "./clientHelper"; import { ConsequenceBan, ConsequenceRedact } from "../../src/protections/consequence"; describe("Test: standard consequences", function () {