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 122e2ba6..cc80a3f2 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"; @@ -58,7 +66,7 @@ export class Mjolnir { */ private unlistedUserRedactionQueue = new UnlistedUserRedactionQueue(); - private protectedRoomsConfig: ProtectedRoomsConfig; + public readonly protectedRoomsConfig: ProtectedRoomsConfig; public readonly protectedRoomsTracker: ProtectedRoomsSet; private webapis: WebAPIs; private openMetrics: OpenMetrics; @@ -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. */ @@ -186,9 +199,6 @@ export class Mjolnir { "Mjolnir is starting up. Use !mjolnir to query status.", ); Mjolnir.addJoinOnInviteListener(mjolnir, client, config); - - mjolnir.moderators = new ModCache(mjolnir.client, mjolnir.matrixEmitter, mjolnir.managementRoomId); - return mjolnir; } @@ -210,10 +220,31 @@ export class Mjolnir { matrixEmitter.on("room.message", async (roomId, event) => { const eventContent = event.content; - if (roomId !== this.managementRoomId) return; if (typeof eventContent !== "object") return; - const { msgtype, body: originalBody, sender, event_id } = eventContent; + 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 } = eventContent; + const eventId = event.event_id; if (msgtype !== "m.text" || typeof originalBody !== "string") { return; } @@ -245,7 +276,7 @@ export class Mjolnir { eventContent.body = COMMAND_PREFIX + restOfBody; LogService.info("Mjolnir", `Command being run by ${sender}: ${eventContent.body}`); - client.sendReadReceipt(roomId, event_id).catch((e: any) => { + client.sendReadReceipt(roomId, eventId).catch((e: any) => { LogService.warn("Mjolnir", "Error sending read receipt: ", e); }); return handleCommand(roomId, event, this); @@ -287,6 +318,8 @@ export class Mjolnir { this.protectionManager = new ProtectionManager(this); this.managementRoomOutput = new ManagementRoomOutput(managementRoomId, client, config); + + this.moderators = new ModCache(client, matrixEmitter, managementRoomId); this.protectedRoomsTracker = new ProtectedRoomsSet( client, clientUserId, diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 5b7c5a02..3271df66 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -146,35 +146,49 @@ export async function handleCommand(roomId: string, event: { content: { body: st return await execIgnoreCommand(roomId, event, mjolnir, parts); } else if (parts[1] === "ignored") { return await execListIgnoredCommand(roomId, event, mjolnir, parts); - } else { + } else if (parts[1] === "help") { // Help menu - const menu = + const protectionMenu = "" + - "!mjolnir - Print status information\n" + - "!mjolnir status - Print status information\n" + "!mjolnir status protection [subcommand] - Print status information for a protection\n" + - "!mjolnir ban [reason] - Adds an entity to the ban list\n" + - "!mjolnir unban [apply] - Removes an entity from the ban list. If apply is 'true', the users matching the glob will actually be unbanned\n" + - "!mjolnir redact [room alias/ID] [limit] - Redacts messages by the sender in the target room (or all rooms), up to a maximum number of events in the backlog (default 1000)\n" + - "!mjolnir redact - Redacts a message by permalink\n" + - "!mjolnir kick [room alias/ID] [reason] - Kicks a user or all of those matching a glob in a particular room or all protected rooms\n" + - "!mjolnir rules - Lists the rules currently in use by Mjolnir\n" + - "!mjolnir rules matching - Lists the rules in use that will match this entity e.g. `!rules matching @foo:example.com` will show all the user and server rules, including globs, that match this user\n" + - "!mjolnir sync - Force updates of all lists and re-apply rules\n" + - "!mjolnir verify - Ensures Mjolnir can moderate all your rooms\n" + - "!mjolnir list create - Creates a new ban list with the given shortcode and alias\n" + - "!mjolnir watch - Watches a ban list\n" + - "!mjolnir unwatch - Unwatches a ban list\n" + - "!mjolnir import - Imports bans and ACLs into the given list\n" + - "!mjolnir default - Sets the default list for commands\n" + - "!mjolnir deactivate - Deactivates a user ID\n" + "!mjolnir protections - List all available protections\n" + "!mjolnir enable - Enables a particular protection\n" + "!mjolnir disable - Disables a particular protection\n" + "!mjolnir config set . [value] - Change a protection setting\n" + "!mjolnir config add . [value] - Add a value to a list protection setting\n" + "!mjolnir config remove . [value] - Remove a value from a list protection setting\n" + - "!mjolnir config get [protection] - List protection settings\n" + + "!mjolnir config get [protection] - List protection settings\n"; + + const actionMenu = + "" + + "!mjolnir ban [reason] - Adds an entity to the ban list\n" + + "!mjolnir unban [apply] - Removes an entity from the ban list. If apply is 'true', the users matching the glob will be manually unbanned in each protected room.\n" + + "!mjolnir redact [room alias/ID] [limit] - Redacts messages by the sender in the target room (or all rooms), up to a maximum number of events in the backlog (default 1000)\n" + + "!mjolnir redact - Redacts a message by permalink\n" + + "!mjolnir kick [room alias/ID] [reason] - Kicks a user or all of those matching a glob in a particular room or all protected rooms\n" + + "!mjolnir deactivate - Deactivates a user ID\n" + + "!mjolnir since / [rooms...] [reason] - Apply an action ('kick', 'ban', 'mute', 'unmute' or 'show') to all users who joined a room since / (up to users)\n" + + "!mjolnir powerlevel [room alias/ID] - Sets the power level of the user in the specified room (or all protected rooms) - mjolnir will resist lowering the power level of the bot/users in the moderation room unless a --force argument is added\n" + + "!mjolnir make admin [user alias/ID] - Make the specified user or the bot itself admin of the room\n" + + "!mjolnir suspend - Suspend the specified user\n" + + "!mjolnir unsuspend - Unsuspend the specified user\n" + + "!mjolnir ignore - Add user to list of users/servers that cannot be banned/ACL'd. Note that this does not survive restart.\n" + + "!mjolnir ignored - List currently ignored entities.\n" + + "!mjolnir shutdown room [message] - Uses the bot's account to shut down a room, preventing access to the room on this server\n"; + + const policyListMenu = + "" + + "!mjolnir list create - Creates a new ban list with the given shortcode and alias\n" + + "!mjolnir watch - Watches a ban list\n" + + "!mjolnir unwatch - Unwatches a ban list\n" + + "!mjolnir import - Imports bans and ACLs into the given list\n" + + "!mjolnir default - Sets the default list for commands\n" + + "!mjolnir rules - Lists the rules currently in use by Mjolnir\n" + + "!mjolnir rules matching - Lists the rules in use that will match this entity e.g. `!rules matching @foo:example.com` will show all the user and server rules, including globs, that match this user\n" + + "!mjolnir sync - Force updates of all lists and re-apply rules\n"; + + const roomsMenu = + "" + "!mjolnir rooms - Lists all the protected rooms\n" + "!mjolnir rooms add - Adds a protected room (may cause high server load)\n" + "!mjolnir rooms remove - Removes a protected room\n" + @@ -185,20 +199,30 @@ export async function handleCommand(roomId: string, event: { content: { body: st "!mjolnir alias add - Adds to \n" + "!mjolnir alias remove - Deletes the room alias from whatever room it is attached to\n" + "!mjolnir resolve - Resolves a room alias to a room ID\n" + - "!mjolnir since / [rooms...] [reason] - Apply an action ('kick', 'ban', 'mute', 'unmute' or 'show') to all users who joined a room since / (up to users)\n" + - "!mjolnir shutdown room [message] - Uses the bot's account to shut down a room, preventing access to the room on this server\n" + - "!mjolnir powerlevel [room alias/ID] - Sets the power level of the user in the specified room (or all protected rooms) - mjolnir will resist lowering the power level of the bot/users in the moderation room unless a --force argument is added\n" + - "!mjolnir make admin [user alias/ID] - Make the specified user or the bot itself admin of the room\n" + - "!mjolnir suspend - Suspend the specified user\n" + - "!mjolnir unsuspend - Unsuspend the specified user\n" + - "!mjolnir ignore - Add user to list of users/servers that cannot be banned/ACL'd. Note that this does not survive restart.\n" + - "!mjolnir ignored - List currently ignored entities.\n" + + "!mjolnir shutdown room [message] - Uses the bot's account to shut down a room, preventing access to the room on this server\n"; + + const botMenu = + "" + + "!mjolnir - Print status information\n" + + "!mjolnir status - Print status information\n" + + "!mjolnir verify - Ensures Mjolnir can moderate all your rooms\n" + "!mjolnir help - This menu\n"; - const html = `Mjolnir help:
${htmlEscape(menu)}
`; - const text = `Mjolnir help:\n${menu}`; + + const html = `

Mjolnir help menu:


+ Protection Actions/Options:
${htmlEscape(protectionMenu)}

+ Moderation Actions:
${htmlEscape(actionMenu)}

+ Policy List Options/Actions:
${htmlEscape(policyListMenu)}

+ Room Managment:
${htmlEscape(roomsMenu)}

+ Bot Status and Management:
${htmlEscape(botMenu)}
`; + const text = `Mjolnir help menu:\n Protection Actions/Options:\n ${protectionMenu} \n Moderation Actions: ${actionMenu}\n Policy List Options/Actions: \n ${policyListMenu} \n Room Management: ${roomsMenu} \n Bot Status and Management: \n ${botMenu} `; const reply = RichReply.createFor(roomId, event, text, html); reply["msgtype"] = "m.notice"; return await mjolnir.client.sendMessage(roomId, reply); + } else { + return await mjolnir.client.sendMessage(roomId, { + msgtype: "m.text", + body: "Unknown command - use `!mjolnir help` to display the help menu.", + }); } } catch (e) { LogService.error("CommandHandler", extractRequestError(e)); diff --git a/src/commands/ShutdownRoomCommand.ts b/src/commands/ShutdownRoomCommand.ts index 7e7b0ebf..880275db 100644 --- a/src/commands/ShutdownRoomCommand.ts +++ b/src/commands/ShutdownRoomCommand.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { Mjolnir } from "../Mjolnir"; -import { RichReply } from "@vector-im/matrix-bot-sdk"; +import { LogLevel, RichReply } from "@vector-im/matrix-bot-sdk"; // !mjolnir shutdown room [] export async function execShutdownRoomCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { @@ -31,6 +31,21 @@ export async function execShutdownRoomCommand(roomId: string, event: any, mjolni return; } + let protectedRooms; + if (mjolnir.config.protectAllJoinedRooms) { + protectedRooms = await mjolnir.client.getJoinedRooms(); + } else { + protectedRooms = mjolnir.protectedRoomsConfig.getExplicitlyProtectedRooms(); + } + if (protectedRooms.includes(target)) { + await mjolnir.managementRoomOutput.logMessage( + LogLevel.INFO, + "ShutdownRoomCommand", + "You are attempting to shutdown a room that mjolnir currently protects, aborting.", + ); + return; + } + await mjolnir.shutdownSynapseRoom(await mjolnir.client.resolveRoom(target), reason); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } 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/commands/UnbanBanTest.ts b/test/integration/commands/UnbanBanTest.ts new file mode 100644 index 00000000..ef6c5172 --- /dev/null +++ b/test/integration/commands/UnbanBanTest.ts @@ -0,0 +1,163 @@ +/* +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 { strict as assert } from "assert"; + +import { newTestUser, noticeListener } from "../clientHelper"; +import { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { getFirstReaction } from "./commandUtils"; + +describe("Test: UnBan function", function () { + let moderator: MatrixClient; + let testRoom: string; + let badUser: MatrixClient; + let badId: string; + this.beforeEach(async function () { + moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "unban-test-moderator" } }); + badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "unban-test-bad-user" } }); + badId = await badUser.getUserId(); + await moderator.start(); + const mjolnirId = await this.mjolnir.client.getUserId(); + testRoom = await moderator.createRoom({ preset: "public_chat" }); + await moderator.joinRoom(this.config.managementRoom); + await this.mjolnir.client.joinRoom(testRoom); + await badUser.joinRoom(testRoom); + await moderator.setUserPowerLevel(mjolnirId, testRoom, 100); + + await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir list create COC code-of-conduct-ban-list-2`, + }); + let reply: Promise = new Promise((resolve, reject) => { + moderator.on( + "room.message", + noticeListener(this.mjolnir.managementRoomId, (event) => { + if (event.content.body.includes("Created new list")) { + resolve(event); + } + }), + ); + }); + await reply; + + moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir rooms add ${testRoom}`, + }); + }); + this.afterEach(async function () { + // unwatch coc + await getFirstReaction(moderator, this.mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir unwatch #code-of-conduct-ban-list-2:localhost:9999`, + }); + }); + // remove alias + await this.mjolnir.client.deleteRoomAlias("#code-of-conduct-ban-list-2:localhost:9999"); + await moderator.stop(); + }); + function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + it("Unban command unbans user when requested", async function () { + this.timeout(20000); + await badUser.sendMessage(testRoom, { msgtype: "m.text", body: "spammy spam" }); + + await getFirstReaction(moderator, this.mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir ban COC ${badId} spam`, + }); + }); + await delay(1000); + // verify that user is banned + const membership = await moderator.getRoomStateEvent(testRoom, "m.room.member", badId); + assert.equal(membership["membership"], "ban"); + + // use unban command + await getFirstReaction(moderator, this.mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir unban COC user ${badId} true`, + }); + }); + + await delay(1000); + // verify that they can join room + await badUser.joinRoom(testRoom); + + // and send messages without being redacted + const newMessageId = await badUser.sendMessage(testRoom, { + msgtype: "m.text", + body: "I am no longer redacted", + }); + const fetchedEvent = await moderator.getEvent(testRoom, newMessageId); + assert.equal(Object.keys(fetchedEvent.content).length, 2, "This event should not have been redacted"); + }); + + it("Unban command removes user from autoredact list when banned via protection", async function () { + this.timeout(20000); + + await getFirstReaction(moderator, this.mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir enable BasicFloodingProtection`, + }); + }); + + let messageId!: string; + for (let i = 0; i < 11; i++) { + messageId = await badUser.sendMessage(testRoom, { msgtype: "m.text", body: "spam content" }); + } + await delay(1000); + // verify they've been banned + const membership = await moderator.getRoomStateEvent(testRoom, "m.room.member", badId); + assert.equal(membership["membership"], "ban"); + + // verify they're being redacted + let redactedMessage = await moderator.getEvent(testRoom, messageId); + assert.equal(Object.keys(redactedMessage.content).length, 0, "This event should have been redacted."); + + // check that they are in the autoredact queue as well + const inQueue = this.mjolnir.unlistedUserRedactionHandler.isUserQueued(badId); + assert.equal(inQueue, true); + + // use unban command + await getFirstReaction(moderator, this.mjolnir.managementRoomId, "✅", async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir unban COC user ${badId} true`, + }); + }); + + // test that unbanned user can join room + await badUser.joinRoom(testRoom); + + // and send events without being redacted + const newMessageId = await badUser.sendMessage(testRoom, { + msgtype: "m.text", + body: "I am no longer redacted", + }); + const fetchedEvent = await moderator.getEvent(testRoom, newMessageId); + assert.equal(Object.keys(fetchedEvent.content).length, 2, "This event should not have been redacted"); + + // and are no longer in autoredact queue + const stillInQueue = this.mjolnir.unlistedUserRedactionHandler.isUserQueued(badId); + assert.equal(stillInQueue, false); + }); +}); diff --git a/test/integration/commands/shutdownCommandTest.ts b/test/integration/commands/shutdownCommandTest.ts index 7c79234f..cdb661f7 100644 --- a/test/integration/commands/shutdownCommandTest.ts +++ b/test/integration/commands/shutdownCommandTest.ts @@ -1,9 +1,11 @@ import { strict as assert } from "assert"; import { newTestUser } from "../clientHelper"; +import { getFirstReaction } from "./commandUtils"; +import { MatrixClient } from "@vector-im/matrix-bot-sdk"; describe("Test: shutdown command", function () { - let client; + let client: MatrixClient; this.beforeEach(async function () { client = await newTestUser(this.config.homeserverUrl, { name: { contains: "shutdown-command" } }); await client.start(); @@ -34,7 +36,7 @@ describe("Test: shutdown command", function () { }); const reply2 = new Promise((resolve, reject) => { - this.mjolnir.client.on("room.event", (roomId, event) => { + this.mjolnir.client.on("room.event", (roomId: string, event: any) => { if ( roomId !== this.mjolnir.managementRoomId && roomId !== badRoom && @@ -54,4 +56,42 @@ describe("Test: shutdown command", function () { return e.message.endsWith('{"errcode":"M_UNKNOWN","error":"This room has been blocked on this server"}'); }); }); + it("Mjolnir will not shutdown a room it is protecting.", async function () { + this.timeout(20000); + const targetRoom = await client.createRoom({ preset: "public_chat" }); + await client.joinRoom(this.mjolnir.managementRoomId); + const otherUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "shutdown-command-extra" }, + }); + + await getFirstReaction(client, this.mjolnir.managementRoomId, "✅", async () => { + return await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir rooms add ${targetRoom}`, + }); + }); + + await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir shutdown room ${targetRoom}`, + }); + + let reply = new Promise((resolve, reject) => { + client.on("room.message", (roomId: string, event: any) => { + console.log(JSON.stringify(event)); + if ( + roomId === this.mjolnir.managementRoomId && + event.content?.body.includes( + "You are attempting to shutdown a room that mjolnir currently protects, aborting", + ) + ) { + resolve(event); + } + }); + }); + await reply; + // room should not be shutdown and available to join + const joined = await otherUser.joinRoom(targetRoom); + await otherUser.sendMessage(joined, { msgtype: "m.text", body: "it's fine to interact with this room" }); + }); }); 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 () {