From 6eadd003098e6ba369d5373fb5a6cc813bf99b40 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 8 Oct 2024 15:13:17 -0700 Subject: [PATCH 01/12] add a list of users/servers who should not be banned, acl'd --- src/Mjolnir.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 9fcf885d..90efdf07 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -86,6 +86,11 @@ export class Mjolnir { public readonly policyListManager: PolicyListManager; + /** + * Members of the moderator room and others who should not be banned, ACL'd etc. + */ + public moderators: string[] + /** * Adds a listener to the client that will automatically accept invitations. * @param {MatrixSendClient} client @@ -156,6 +161,18 @@ export class Mjolnir { const mjolnir = new Mjolnir(client, await client.getUserId(), matrixEmitter, managementRoomId, config, ruleServer); await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status."); Mjolnir.addJoinOnInviteListener(mjolnir, client, config); + + const memberEvents = await mjolnir.client.getRoomMembers(managementRoomId, undefined, ["join"], ["ban", "leave"]) + let moderators: string[] = [] + memberEvents.forEach(event => { + moderators.push(event.stateKey) + const server = event.stateKey.split(":")[1] + if (!moderators.includes(server)) { + moderators.push(server) + } + }) + mjolnir.moderators = moderators + return mjolnir; } @@ -251,7 +268,8 @@ export class Mjolnir { managementRoomId, this.managementRoomOutput, this.protectionManager, - config); + config, + this.moderators); } public get state(): string { From fab0640fdd93f2dfabe3554ced1ee7e24308628f Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 8 Oct 2024 15:13:58 -0700 Subject: [PATCH 02/12] check list of entities who shouldn't be banned before banning --- src/ProtectedRoomsSet.ts | 6 ++++++ src/commands/SinceCommand.ts | 4 ++++ src/commands/UnbanBanCommand.ts | 8 ++++++++ src/protections/BasicFlooding.ts | 4 ++++ src/protections/FirstMessageIsImage.ts | 4 ++++ src/protections/ProtectionManager.ts | 4 ++++ src/protections/TrustedReporters.ts | 6 ++++++ src/report/ReportManager.ts | 6 +++++- 8 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/ProtectedRoomsSet.ts b/src/ProtectedRoomsSet.ts index 5f2908c9..2234b352 100644 --- a/src/ProtectedRoomsSet.ts +++ b/src/ProtectedRoomsSet.ts @@ -106,6 +106,7 @@ export class ProtectedRoomsSet { private readonly managementRoomOutput: ManagementRoomOutput, private readonly protectionManager: ProtectionManager, private readonly config: IConfig, + private readonly moderators: string[] ) { for (const reason of this.config.automaticallyRedactForReasons) { this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase())); @@ -389,6 +390,11 @@ export class ProtectedRoomsSet { await this.managementRoomOutput.logMessage(LogLevel.INFO, "ApplyBan", `Banning ${member.userId} in ${roomId} for: ${reason}`, roomId); if (!this.config.noop) { + if (this.moderators.includes(member.userId)) { + await this.managementRoomOutput.logMessage(LogLevel.WARN, "ApplyBan", `Attempted + to ban ${member.userId} but this is a member of the management room, skipping.`); + continue; + } await this.client.banUser(member.userId, roomId, memberAccess.rule!.reason); if (this.automaticRedactGlobs.find(g => g.test(reason.toLowerCase()))) { this.redactUser(member.userId, roomId); diff --git a/src/commands/SinceCommand.ts b/src/commands/SinceCommand.ts index 572ce7b0..fcbcccd6 100644 --- a/src/commands/SinceCommand.ts +++ b/src/commands/SinceCommand.ts @@ -248,6 +248,10 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni case Action.Ban: { for (let join of recentJoins) { try { + if (mjolnir.moderators.includes(join.userId)) { + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "SinceCommand", `Attempting to ban user ${join.userId} but this is a member of the management room, skipping.`); + continue; + } await mjolnir.client.banUser(join.userId, targetRoomId, reason); results.succeeded.push(join.userId); } catch (ex) { diff --git a/src/commands/UnbanBanCommand.ts b/src/commands/UnbanBanCommand.ts index abaa9fce..696ebe6e 100644 --- a/src/commands/UnbanBanCommand.ts +++ b/src/commands/UnbanBanCommand.ts @@ -118,6 +118,14 @@ export async function execBanCommand(roomId: string, event: any, mjolnir: Mjolni const bits = await parseArguments(roomId, event, mjolnir, parts); if (!bits) return; // error already handled + const matcher = new MatrixGlob(bits.entity); + mjolnir.moderators.forEach(async(name) => { + if (matcher.test(name)) { + await mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "UnbanBanCommand", `The ban command ${bits.entity} matches user in moderation room ${name}, aborting command.`); + return; + } + }) + await bits.list!.banEntity(bits.ruleType!, bits.entity, bits.reason); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); } diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index a76b8171..5a1c0f18 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -64,6 +64,10 @@ export class BasicFlooding extends Protection { if (messageCount >= this.settings.maxPerMinute.value) { await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`, roomId); if (!mjolnir.config.noop) { + if (mjolnir.moderators.includes(event["sender"])) { + mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Attempting to ban ${event["sender"]} but this is a member of the management room, aborting.`); + return; + } await mjolnir.client.banUser(event['sender'], roomId, "spam"); } else { await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); diff --git a/src/protections/FirstMessageIsImage.ts b/src/protections/FirstMessageIsImage.ts index 31cfe033..175ba8b1 100644 --- a/src/protections/FirstMessageIsImage.ts +++ b/src/protections/FirstMessageIsImage.ts @@ -58,6 +58,10 @@ export class FirstMessageIsImage extends Protection { if (isMedia && this.justJoined[roomId].includes(event['sender'])) { await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Banning ${event['sender']} for posting an image as the first thing after joining in ${roomId}.`); if (!mjolnir.config.noop) { + if (mjolnir.moderators.includes(event["sender"])) { + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Attempting to ban ${event["sender"]} but they are member of moderation room, aborting.`); + return; + } await mjolnir.client.banUser(event['sender'], roomId, "spam"); } else { await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); diff --git a/src/protections/ProtectionManager.ts b/src/protections/ProtectionManager.ts index 9b843186..fc559899 100644 --- a/src/protections/ProtectionManager.ts +++ b/src/protections/ProtectionManager.ts @@ -269,6 +269,10 @@ export class ProtectionManager { if (consequence.name === "alert") { /* take no additional action, just print the below message to management room */ } else if (consequence.name === "ban") { + if (this.mjolnir.moderators.includes(sender)) { + await this.mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "ProtectionManager", `Attempting to ban ${sender} but this is a member of management room, skipping.`); + continue; + } await this.mjolnir.client.banUser(sender, roomId, "abuse detected"); } else if (consequence.name === "redact") { await this.mjolnir.client.redactEvent(roomId, eventId, "abuse detected"); diff --git a/src/protections/TrustedReporters.ts b/src/protections/TrustedReporters.ts index 99c1a186..7c29524a 100644 --- a/src/protections/TrustedReporters.ts +++ b/src/protections/TrustedReporters.ts @@ -17,6 +17,7 @@ limitations under the License. import { Protection } from "./IProtection"; import { MXIDListProtectionSetting, NumberProtectionSetting } from "./ProtectionSettings"; import { Mjolnir } from "../Mjolnir"; +import {LogLevel} from "@vector-im/matrix-bot-sdk"; const MAX_REPORTED_EVENT_BACKLOG = 20; @@ -76,6 +77,11 @@ export class TrustedReporters extends Protection { await mjolnir.client.redactEvent(roomId, event.id, "abuse detected"); } if (reporters.size === this.settings.banThreshold.value) { + if (mjolnir.moderators.includes(event.userId)) { + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "TrustedReporters", `Attempting to ban + ${event.userId} but this is a member of the management room, aborting.`); + return; + } met.push("ban"); await mjolnir.client.banUser(event.userId, roomId, "abuse detected"); } diff --git a/src/report/ReportManager.ts b/src/report/ReportManager.ts index d00c3bbc..b1595c7f 100644 --- a/src/report/ReportManager.ts +++ b/src/report/ReportManager.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { PowerLevelAction } from "@vector-im/matrix-bot-sdk/lib/models/PowerLevelAction"; -import { LogService, UserID } from "@vector-im/matrix-bot-sdk"; +import {LogLevel, LogService, UserID} from "@vector-im/matrix-bot-sdk"; import { htmlToText } from "html-to-text"; import { htmlEscape } from "../utils"; import { JSDOM } from 'jsdom'; @@ -656,6 +656,10 @@ class BanAccused implements IUIAction { return `Ban ${htmlEscape(report.accused_id)} from room ${htmlEscape(report.room_alias_or_id)}`; } public async execute(manager: ReportManager, report: IReport): Promise { + if (manager.mjolnir.moderators.includes(report.accused_id)) { + await manager.mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "ReportManager", `Attempting to ban ${report.accused_id} but this is a member of management room, aborting.`); + return; + } await manager.mjolnir.client.banUser(report.accused_id, report.room_id); return; } From 8fc5d0548fa6b390970b8d7fbc67b3f4a3e2990b Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Wed, 9 Oct 2024 12:30:02 -0700 Subject: [PATCH 03/12] add command to add users to ignored list --- src/commands/CommandHandler.ts | 11 ++++++-- src/commands/IgnoreCommand.ts | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 src/commands/IgnoreCommand.ts diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 36384237..0b345aab 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -45,6 +45,7 @@ import { execSinceCommand } from "./SinceCommand"; import { execSetupProtectedRoom } from "./SetupDecentralizedReportingCommand"; import {execSuspendCommand} from "./SuspendCommand"; import {execUnsuspendCommand} from "./UnsuspendCommand"; +import {execIgnoreCommand, execListIgnoredCommand} from "./IgnoreCommand"; export const COMMAND_PREFIX = "!mjolnir"; @@ -134,6 +135,10 @@ export async function handleCommand(roomId: string, event: { content: { body: st return await execSuspendCommand(roomId, event, mjolnir, parts); } else if (parts[1] === 'unsuspend' && parts.length > 2) { return await execUnsuspendCommand(roomId, event, mjolnir, parts) + } else if (parts[1] === 'ignore') { + return await execIgnoreCommand(roomId, event, mjolnir, parts) + } else if (parts[1] === 'ignored') { + return await execListIgnoredCommand(roomId, event, mjolnir, parts) } else { // Help menu const menu = "" + @@ -176,8 +181,10 @@ export async function handleCommand(roomId: string, event: { content: { body: st "!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)\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" + - "!mjolnir unsuspend - Unsuspend the specified user" + + "!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 help - This menu\n" const html = `Mjolnir help:
${htmlEscape(menu)}
`; const text = `Mjolnir help:\n${menu}`; diff --git a/src/commands/IgnoreCommand.ts b/src/commands/IgnoreCommand.ts new file mode 100644 index 00000000..511b56f3 --- /dev/null +++ b/src/commands/IgnoreCommand.ts @@ -0,0 +1,46 @@ +/* +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 {Mjolnir} from "../Mjolnir"; +import {LogLevel, RichReply} from "@vector-im/matrix-bot-sdk"; + +// !mjolnir ignore +export async function execIgnoreCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + const target = parts[2]; + + await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "IgnoreCommand", `Adding ${target} to internal moderator list.`); + mjolnir.moderators.push(target) + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); +} + +// !mjolnir ignored +export async function execListIgnoredCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + + let html = "Ignored users:
    "; + let text = "Ignored users:\n"; + + for (const name of mjolnir.moderators) { + html += `
  • ${name}
  • `; + text += `* ${name}\n`; + } + + html += "
"; + + const reply = RichReply.createFor(roomId, event, text, html); + reply["msgtype"] = "m.notice"; + await mjolnir.client.sendMessage(roomId, reply); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); +} \ No newline at end of file From 22650117c080570b99bda38823767bf9525a6297 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Wed, 9 Oct 2024 14:16:23 -0700 Subject: [PATCH 04/12] use correct file when logging --- src/protections/FirstMessageIsImage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/protections/FirstMessageIsImage.ts b/src/protections/FirstMessageIsImage.ts index 175ba8b1..cea42670 100644 --- a/src/protections/FirstMessageIsImage.ts +++ b/src/protections/FirstMessageIsImage.ts @@ -59,7 +59,7 @@ export class FirstMessageIsImage extends Protection { await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Banning ${event['sender']} for posting an image as the first thing after joining in ${roomId}.`); if (!mjolnir.config.noop) { if (mjolnir.moderators.includes(event["sender"])) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Attempting to ban ${event["sender"]} but they are member of moderation room, aborting.`); + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Attempting to ban ${event["sender"]} but they are member of moderation room, aborting.`); return; } await mjolnir.client.banUser(event['sender'], roomId, "spam"); From 5c708035c0be3033500bc549f6a124e1cdd81848 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Wed, 9 Oct 2024 14:22:54 -0700 Subject: [PATCH 05/12] lint --- src/Mjolnir.ts | 26 ++++++++++++++++---------- src/ProtectedRoomsSet.ts | 10 +++++++--- src/commands/CommandHandler.ts | 20 ++++++++++---------- src/commands/IgnoreCommand.ts | 19 +++++++++++-------- src/commands/SinceCommand.ts | 6 +++++- src/commands/UnbanBanCommand.ts | 10 +++++++--- src/protections/BasicFlooding.ts | 8 ++++++-- src/protections/FirstMessageIsImage.ts | 8 ++++++-- src/protections/ProtectionManager.ts | 6 +++++- src/protections/TrustedReporters.ts | 10 +++++++--- src/report/ReportManager.ts | 8 ++++++-- 11 files changed, 86 insertions(+), 45 deletions(-) diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 2cab5060..abab73ed 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -84,7 +84,7 @@ export class Mjolnir { /** * Members of the moderator room and others who should not be banned, ACL'd etc. */ - public moderators: string[] + public moderators: string[]; /** * Adds a listener to the client that will automatically accept invitations. @@ -186,16 +186,21 @@ export class Mjolnir { ); Mjolnir.addJoinOnInviteListener(mjolnir, client, config); - const memberEvents = await mjolnir.client.getRoomMembers(managementRoomId, undefined, ["join"], ["ban", "leave"]) - let moderators: string[] = [] - memberEvents.forEach(event => { - moderators.push(event.stateKey) - const server = event.stateKey.split(":")[1] + const memberEvents = await mjolnir.client.getRoomMembers( + managementRoomId, + undefined, + ["join"], + ["ban", "leave"], + ); + let moderators: string[] = []; + memberEvents.forEach((event) => { + moderators.push(event.stateKey); + const server = event.stateKey.split(":")[1]; if (!moderators.includes(server)) { - moderators.push(server) + moderators.push(server); } - }) - mjolnir.moderators = moderators + }); + mjolnir.moderators = moderators; return mjolnir; } @@ -296,7 +301,8 @@ export class Mjolnir { this.managementRoomOutput, this.protectionManager, config, - this.moderators); + this.moderators, + ); } public get state(): string { diff --git a/src/ProtectedRoomsSet.ts b/src/ProtectedRoomsSet.ts index edce2499..e4614699 100644 --- a/src/ProtectedRoomsSet.ts +++ b/src/ProtectedRoomsSet.ts @@ -108,7 +108,7 @@ export class ProtectedRoomsSet { private readonly managementRoomOutput: ManagementRoomOutput, private readonly protectionManager: ProtectionManager, private readonly config: IConfig, - private readonly moderators: string[] + private readonly moderators: string[], ) { for (const reason of this.config.automaticallyRedactForReasons) { this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase())); @@ -443,8 +443,12 @@ export class ProtectedRoomsSet { if (!this.config.noop) { if (this.moderators.includes(member.userId)) { - await this.managementRoomOutput.logMessage(LogLevel.WARN, "ApplyBan", `Attempted - to ban ${member.userId} but this is a member of the management room, skipping.`); + await this.managementRoomOutput.logMessage( + LogLevel.WARN, + "ApplyBan", + `Attempted + to ban ${member.userId} but this is a member of the management room, skipping.`, + ); continue; } await this.client.banUser(member.userId, roomId, memberAccess.rule!.reason); diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index bfd64a66..daa47120 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -48,9 +48,9 @@ import { execMakeRoomAdminCommand } from "./MakeRoomAdminCommand"; import { parse as tokenize } from "shell-quote"; import { execSinceCommand } from "./SinceCommand"; import { execSetupProtectedRoom } from "./SetupDecentralizedReportingCommand"; -import {execSuspendCommand} from "./SuspendCommand"; -import {execUnsuspendCommand} from "./UnsuspendCommand"; -import {execIgnoreCommand, execListIgnoredCommand} from "./IgnoreCommand"; +import { execSuspendCommand } from "./SuspendCommand"; +import { execUnsuspendCommand } from "./UnsuspendCommand"; +import { execIgnoreCommand, execListIgnoredCommand } from "./IgnoreCommand"; export const COMMAND_PREFIX = "!mjolnir"; @@ -140,12 +140,12 @@ export async function handleCommand(roomId: string, event: { content: { body: st return await execMakeRoomAdminCommand(roomId, event, mjolnir, parts); } else if (parts[1] === "suspend" && parts.length > 2) { return await execSuspendCommand(roomId, event, mjolnir, parts); - } else if (parts[1] === 'unsuspend' && parts.length > 2) { - return await execUnsuspendCommand(roomId, event, mjolnir, parts) - } else if (parts[1] === 'ignore') { - return await execIgnoreCommand(roomId, event, mjolnir, parts) - } else if (parts[1] === 'ignored') { - return await execListIgnoredCommand(roomId, event, mjolnir, parts) + } else if (parts[1] === "unsuspend" && parts.length > 2) { + return await execUnsuspendCommand(roomId, event, mjolnir, parts); + } else if (parts[1] === "ignore") { + return await execIgnoreCommand(roomId, event, mjolnir, parts); + } else if (parts[1] === "ignored") { + return await execListIgnoredCommand(roomId, event, mjolnir, parts); } else { // Help menu const menu = @@ -193,7 +193,7 @@ export async function handleCommand(roomId: string, event: { content: { body: st "!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 help - This menu\n" + "!mjolnir help - This menu\n"; const html = `Mjolnir help:
${htmlEscape(menu)}
`; const text = `Mjolnir help:\n${menu}`; const reply = RichReply.createFor(roomId, event, text, html); diff --git a/src/commands/IgnoreCommand.ts b/src/commands/IgnoreCommand.ts index 511b56f3..d893731c 100644 --- a/src/commands/IgnoreCommand.ts +++ b/src/commands/IgnoreCommand.ts @@ -14,21 +14,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Mjolnir} from "../Mjolnir"; -import {LogLevel, RichReply} from "@vector-im/matrix-bot-sdk"; +import { Mjolnir } from "../Mjolnir"; +import { LogLevel, RichReply } from "@vector-im/matrix-bot-sdk"; // !mjolnir ignore export async function execIgnoreCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { const target = parts[2]; - await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "IgnoreCommand", `Adding ${target} to internal moderator list.`); - mjolnir.moderators.push(target) - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.INFO, + "IgnoreCommand", + `Adding ${target} to internal moderator list.`, + ); + mjolnir.moderators.push(target); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } // !mjolnir ignored export async function execListIgnoredCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - let html = "Ignored users:
    "; let text = "Ignored users:\n"; @@ -42,5 +45,5 @@ export async function execListIgnoredCommand(roomId: string, event: any, mjolnir const reply = RichReply.createFor(roomId, event, text, html); reply["msgtype"] = "m.notice"; await mjolnir.client.sendMessage(roomId, reply); - await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); -} \ No newline at end of file + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); +} diff --git a/src/commands/SinceCommand.ts b/src/commands/SinceCommand.ts index f7b51017..b4dfc76d 100644 --- a/src/commands/SinceCommand.ts +++ b/src/commands/SinceCommand.ts @@ -267,7 +267,11 @@ async function execSinceCommandAux( for (let join of recentJoins) { try { if (mjolnir.moderators.includes(join.userId)) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "SinceCommand", `Attempting to ban user ${join.userId} but this is a member of the management room, skipping.`); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "SinceCommand", + `Attempting to ban user ${join.userId} but this is a member of the management room, skipping.`, + ); continue; } await mjolnir.client.banUser(join.userId, targetRoomId, reason); diff --git a/src/commands/UnbanBanCommand.ts b/src/commands/UnbanBanCommand.ts index 174b98be..4ac94020 100644 --- a/src/commands/UnbanBanCommand.ts +++ b/src/commands/UnbanBanCommand.ts @@ -126,12 +126,16 @@ export async function execBanCommand(roomId: string, event: any, mjolnir: Mjolni if (!bits) return; // error already handled const matcher = new MatrixGlob(bits.entity); - mjolnir.moderators.forEach(async(name) => { + mjolnir.moderators.forEach(async (name) => { if (matcher.test(name)) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "UnbanBanCommand", `The ban command ${bits.entity} matches user in moderation room ${name}, aborting command.`); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "UnbanBanCommand", + `The ban command ${bits.entity} matches user in moderation room ${name}, aborting command.`, + ); return; } - }) + }); await bits.list!.banEntity(bits.ruleType!, bits.entity, bits.reason); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index 666ee3a5..52e68f3a 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -76,10 +76,14 @@ export class BasicFlooding extends Protection { ); if (!mjolnir.config.noop) { if (mjolnir.moderators.includes(event["sender"])) { - mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "BasicFlooding", `Attempting to ban ${event["sender"]} but this is a member of the management room, aborting.`); + mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "BasicFlooding", + `Attempting to ban ${event["sender"]} but this is a member of the management room, aborting.`, + ); return; } - await mjolnir.client.banUser(event['sender'], roomId, "spam"); + await mjolnir.client.banUser(event["sender"], roomId, "spam"); } else { await mjolnir.managementRoomOutput.logMessage( LogLevel.WARN, diff --git a/src/protections/FirstMessageIsImage.ts b/src/protections/FirstMessageIsImage.ts index 3cbbcd1f..58a1cb8e 100644 --- a/src/protections/FirstMessageIsImage.ts +++ b/src/protections/FirstMessageIsImage.ts @@ -65,10 +65,14 @@ export class FirstMessageIsImage extends Protection { ); if (!mjolnir.config.noop) { if (mjolnir.moderators.includes(event["sender"])) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Attempting to ban ${event["sender"]} but they are member of moderation room, aborting.`); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "FirstMessageIsImage", + `Attempting to ban ${event["sender"]} but they are member of moderation room, aborting.`, + ); return; } - await mjolnir.client.banUser(event['sender'], roomId, "spam"); + await mjolnir.client.banUser(event["sender"], roomId, "spam"); } else { await mjolnir.managementRoomOutput.logMessage( LogLevel.WARN, diff --git a/src/protections/ProtectionManager.ts b/src/protections/ProtectionManager.ts index a807a803..6650f571 100644 --- a/src/protections/ProtectionManager.ts +++ b/src/protections/ProtectionManager.ts @@ -287,7 +287,11 @@ export class ProtectionManager { /* take no additional action, just print the below message to management room */ } else if (consequence.name === "ban") { if (this.mjolnir.moderators.includes(sender)) { - await this.mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "ProtectionManager", `Attempting to ban ${sender} but this is a member of management room, skipping.`); + await this.mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "ProtectionManager", + `Attempting to ban ${sender} but this is a member of management room, skipping.`, + ); continue; } await this.mjolnir.client.banUser(sender, roomId, "abuse detected"); diff --git a/src/protections/TrustedReporters.ts b/src/protections/TrustedReporters.ts index e3c1bbfc..2405bfab 100644 --- a/src/protections/TrustedReporters.ts +++ b/src/protections/TrustedReporters.ts @@ -17,7 +17,7 @@ limitations under the License. import { Protection } from "./IProtection"; import { MXIDListProtectionSetting, NumberProtectionSetting } from "./ProtectionSettings"; import { Mjolnir } from "../Mjolnir"; -import {LogLevel} from "@vector-im/matrix-bot-sdk"; +import { LogLevel } from "@vector-im/matrix-bot-sdk"; const MAX_REPORTED_EVENT_BACKLOG = 20; @@ -84,8 +84,12 @@ export class TrustedReporters extends Protection { } if (reporters.size === this.settings.banThreshold.value) { if (mjolnir.moderators.includes(event.userId)) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "TrustedReporters", `Attempting to ban - ${event.userId} but this is a member of the management room, aborting.`); + await mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "TrustedReporters", + `Attempting to ban + ${event.userId} but this is a member of the management room, aborting.`, + ); return; } met.push("ban"); diff --git a/src/report/ReportManager.ts b/src/report/ReportManager.ts index eb6e24a8..ba998cfd 100644 --- a/src/report/ReportManager.ts +++ b/src/report/ReportManager.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { PowerLevelAction } from "@vector-im/matrix-bot-sdk/lib/models/PowerLevelAction"; -import {LogLevel, LogService, UserID} from "@vector-im/matrix-bot-sdk"; +import { LogLevel, LogService, UserID } from "@vector-im/matrix-bot-sdk"; import { htmlToText } from "html-to-text"; import { htmlEscape } from "../utils"; import { JSDOM } from "jsdom"; @@ -771,7 +771,11 @@ class BanAccused implements IUIAction { } public async execute(manager: ReportManager, report: IReport): Promise { if (manager.mjolnir.moderators.includes(report.accused_id)) { - await manager.mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "ReportManager", `Attempting to ban ${report.accused_id} but this is a member of management room, aborting.`); + await manager.mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "ReportManager", + `Attempting to ban ${report.accused_id} but this is a member of management room, aborting.`, + ); return; } await manager.mjolnir.client.banUser(report.accused_id, report.room_id); From 989ba8ba8a7b86f9c3e9be2bbb87ea48a0253b0a Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 14 Oct 2024 14:42:06 -0700 Subject: [PATCH 06/12] add a cache to track ignore list/moderation room members --- src/Mjolnir.ts | 19 +---- src/ModCache.ts | 106 ++++++++++++++++++++++++++++ test/integration/dontBanSelfTest.ts | 0 yarn.lock | 5 -- 4 files changed, 109 insertions(+), 21 deletions(-) create mode 100644 src/ModCache.ts create mode 100644 test/integration/dontBanSelfTest.ts diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index abab73ed..9e8bcfa6 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -34,6 +34,7 @@ import { RoomMemberManager } from "./RoomMembers"; import ProtectedRoomsConfig from "./ProtectedRoomsConfig"; import { MatrixEmitter, MatrixSendClient } from "./MatrixEmitter"; import { OpenMetrics } from "./webapis/OpenMetrics"; +import { ModCache } from "./ModCache"; export const STATE_NOT_STARTED = "not_started"; export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; @@ -84,7 +85,7 @@ export class Mjolnir { /** * Members of the moderator room and others who should not be banned, ACL'd etc. */ - public moderators: string[]; + public moderators: ModCache; /** * Adds a listener to the client that will automatically accept invitations. @@ -186,21 +187,7 @@ export class Mjolnir { ); Mjolnir.addJoinOnInviteListener(mjolnir, client, config); - const memberEvents = await mjolnir.client.getRoomMembers( - managementRoomId, - undefined, - ["join"], - ["ban", "leave"], - ); - let moderators: string[] = []; - memberEvents.forEach((event) => { - moderators.push(event.stateKey); - const server = event.stateKey.split(":")[1]; - if (!moderators.includes(server)) { - moderators.push(server); - } - }); - mjolnir.moderators = moderators; + mjolnir.moderators = new ModCache(mjolnir.client, mjolnir.matrixEmitter, mjolnir.managementRoomId); return mjolnir; } diff --git a/src/ModCache.ts b/src/ModCache.ts new file mode 100644 index 00000000..5d0a09de --- /dev/null +++ b/src/ModCache.ts @@ -0,0 +1,106 @@ +/* +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 { MatrixEmitter, MatrixSendClient } from "./MatrixEmitter"; + +export class ModCache { + private modRoomMembers: string[] = []; + private ignoreList: string[] = []; + private client: MatrixSendClient; + private emitter: MatrixEmitter; + private managementRoomId: string; + private ttl: number = 1000 * 60 * 60; // 60 minutes + private lastInvalidation = 0; + + constructor(client: MatrixSendClient, emitter: MatrixEmitter, managementRoomId: string) { + this.client = client; + this.emitter = emitter; + this.managementRoomId = managementRoomId; + this.lastInvalidation = Date.now(); + this.init(); + } + + /** + * Initially populate cache and set bot listening for membership events in moderation room + */ + async init() { + await this.populateCache(); + setInterval( + () => { + if (Date.now() - this.lastInvalidation > this.ttl) { + this.populateCache(); + } + }, + 1000 * 60, // check invalidation status every minute + ); + this.emitter.on("room.event", async (roomId: string, event: any) => { + if (roomId === this.managementRoomId && event.type === "m.room.member") { + await this.populateCache(); + this.lastInvalidation = Date.now(); + } + }); + } + + /** + * Populate the cache by fetching moderation room membership events + */ + public async populateCache() { + const memberEvents = await this.client.getRoomMembers( + this.managementRoomId, + undefined, + ["join", "invite"], + ["ban", "leave"], + ); + this.modRoomMembers = []; + memberEvents.forEach((event) => { + if (!this.modRoomMembers.includes(event.stateKey)) { + this.modRoomMembers.push(event.stateKey); + } + const server = event.stateKey.split(":")[1]; + if (!this.modRoomMembers.includes(server)) { + this.modRoomMembers.push(server); + } + }); + } + + /** + * Check if a given entity is in cache + */ + public checkMembership(entity: string) { + return this.modRoomMembers.includes(entity) || this.ignoreList.includes(entity); + } + + /** + * Add a given entity to the list of users/servers who will not be banned but are not necessarily in moderator room + */ + public addToIgnore(entity: string) { + this.ignoreList.push(entity); + } + + /** + * Return a list of entities to ignore bans/ACLs for + */ + public listIgnored() { + return this.ignoreList; + } + + /** + * Return a list of both ignored entities and moderator room members + */ + public listAll() { + return this.ignoreList.concat(this.modRoomMembers); + } +} diff --git a/test/integration/dontBanSelfTest.ts b/test/integration/dontBanSelfTest.ts new file mode 100644 index 00000000..e69de29b diff --git a/yarn.lock b/yarn.lock index 4fef91cf..29113bca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3218,11 +3218,6 @@ prelude-ls@~1.1.2: resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz" integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== -prettier@3.3.3: - version "3.3.3" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105" - integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew== - pretty-format@^27.2.4: version "27.2.4" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-27.2.4.tgz" From a7977cc3853d83a8f1b97ab86318700e094c9741 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 14 Oct 2024 14:42:32 -0700 Subject: [PATCH 07/12] refactor to use modcache --- src/ProtectedRoomsSet.ts | 5 +++-- src/commands/IgnoreCommand.ts | 4 ++-- src/commands/SinceCommand.ts | 2 +- src/commands/UnbanBanCommand.ts | 3 ++- src/protections/BasicFlooding.ts | 2 +- src/protections/FirstMessageIsImage.ts | 2 +- src/protections/ProtectionManager.ts | 2 +- src/protections/TrustedReporters.ts | 2 +- src/report/ReportManager.ts | 2 +- 9 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/ProtectedRoomsSet.ts b/src/ProtectedRoomsSet.ts index e4614699..ce6e9b8a 100644 --- a/src/ProtectedRoomsSet.ts +++ b/src/ProtectedRoomsSet.ts @@ -27,6 +27,7 @@ import { ProtectionManager } from "./protections/ProtectionManager"; import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue"; import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker"; import { htmlEscape } from "./utils"; +import { ModCache } from "./ModCache"; /** * This class aims to synchronize `m.ban` rules in a set of policy lists with @@ -108,7 +109,7 @@ export class ProtectedRoomsSet { private readonly managementRoomOutput: ManagementRoomOutput, private readonly protectionManager: ProtectionManager, private readonly config: IConfig, - private readonly moderators: string[], + private readonly moderators: ModCache, ) { for (const reason of this.config.automaticallyRedactForReasons) { this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase())); @@ -442,7 +443,7 @@ export class ProtectedRoomsSet { ); if (!this.config.noop) { - if (this.moderators.includes(member.userId)) { + if (this.moderators.checkMembership(member.userId)) { await this.managementRoomOutput.logMessage( LogLevel.WARN, "ApplyBan", diff --git a/src/commands/IgnoreCommand.ts b/src/commands/IgnoreCommand.ts index d893731c..0788de82 100644 --- a/src/commands/IgnoreCommand.ts +++ b/src/commands/IgnoreCommand.ts @@ -26,7 +26,7 @@ export async function execIgnoreCommand(roomId: string, event: any, mjolnir: Mjo "IgnoreCommand", `Adding ${target} to internal moderator list.`, ); - mjolnir.moderators.push(target); + mjolnir.moderators.addToIgnore(target); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } @@ -35,7 +35,7 @@ export async function execListIgnoredCommand(roomId: string, event: any, mjolnir let html = "Ignored users:
      "; let text = "Ignored users:\n"; - for (const name of mjolnir.moderators) { + for (const name of mjolnir.moderators.listIgnored()) { html += `
    • ${name}
    • `; text += `* ${name}\n`; } diff --git a/src/commands/SinceCommand.ts b/src/commands/SinceCommand.ts index b4dfc76d..5c25f0a8 100644 --- a/src/commands/SinceCommand.ts +++ b/src/commands/SinceCommand.ts @@ -266,7 +266,7 @@ async function execSinceCommandAux( case Action.Ban: { for (let join of recentJoins) { try { - if (mjolnir.moderators.includes(join.userId)) { + if (mjolnir.moderators.checkMembership(join.userId)) { await mjolnir.managementRoomOutput.logMessage( LogLevel.WARN, "SinceCommand", diff --git a/src/commands/UnbanBanCommand.ts b/src/commands/UnbanBanCommand.ts index 4ac94020..66d2a86a 100644 --- a/src/commands/UnbanBanCommand.ts +++ b/src/commands/UnbanBanCommand.ts @@ -126,7 +126,8 @@ export async function execBanCommand(roomId: string, event: any, mjolnir: Mjolni if (!bits) return; // error already handled const matcher = new MatrixGlob(bits.entity); - mjolnir.moderators.forEach(async (name) => { + const moderators = mjolnir.moderators.listAll(); + moderators.forEach(async (name) => { if (matcher.test(name)) { await mjolnir.managementRoomOutput.logMessage( LogLevel.ERROR, diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index 52e68f3a..d6ddc451 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -75,7 +75,7 @@ export class BasicFlooding extends Protection { roomId, ); if (!mjolnir.config.noop) { - if (mjolnir.moderators.includes(event["sender"])) { + if (mjolnir.moderators.checkMembership(event["sender"])) { mjolnir.managementRoomOutput.logMessage( LogLevel.WARN, "BasicFlooding", diff --git a/src/protections/FirstMessageIsImage.ts b/src/protections/FirstMessageIsImage.ts index 58a1cb8e..51574f96 100644 --- a/src/protections/FirstMessageIsImage.ts +++ b/src/protections/FirstMessageIsImage.ts @@ -64,7 +64,7 @@ export class FirstMessageIsImage extends Protection { `Banning ${event["sender"]} for posting an image as the first thing after joining in ${roomId}.`, ); if (!mjolnir.config.noop) { - if (mjolnir.moderators.includes(event["sender"])) { + if (mjolnir.moderators.checkMembership(event["sender"])) { await mjolnir.managementRoomOutput.logMessage( LogLevel.WARN, "FirstMessageIsImage", diff --git a/src/protections/ProtectionManager.ts b/src/protections/ProtectionManager.ts index 6650f571..485f05e1 100644 --- a/src/protections/ProtectionManager.ts +++ b/src/protections/ProtectionManager.ts @@ -286,7 +286,7 @@ export class ProtectionManager { if (consequence.name === "alert") { /* take no additional action, just print the below message to management room */ } else if (consequence.name === "ban") { - if (this.mjolnir.moderators.includes(sender)) { + if (this.mjolnir.moderators.checkMembership(sender)) { await this.mjolnir.managementRoomOutput.logMessage( LogLevel.WARN, "ProtectionManager", diff --git a/src/protections/TrustedReporters.ts b/src/protections/TrustedReporters.ts index 2405bfab..4ebc8e67 100644 --- a/src/protections/TrustedReporters.ts +++ b/src/protections/TrustedReporters.ts @@ -83,7 +83,7 @@ export class TrustedReporters extends Protection { await mjolnir.client.redactEvent(roomId, event.id, "abuse detected"); } if (reporters.size === this.settings.banThreshold.value) { - if (mjolnir.moderators.includes(event.userId)) { + if (mjolnir.moderators.checkMembership(event.userId)) { await mjolnir.managementRoomOutput.logMessage( LogLevel.WARN, "TrustedReporters", diff --git a/src/report/ReportManager.ts b/src/report/ReportManager.ts index ba998cfd..a93dc133 100644 --- a/src/report/ReportManager.ts +++ b/src/report/ReportManager.ts @@ -770,7 +770,7 @@ class BanAccused implements IUIAction { return `Ban ${htmlEscape(report.accused_id)} from room ${htmlEscape(report.room_alias_or_id)}`; } public async execute(manager: ReportManager, report: IReport): Promise { - if (manager.mjolnir.moderators.includes(report.accused_id)) { + if (manager.mjolnir.moderators.checkMembership(report.accused_id)) { await manager.mjolnir.managementRoomOutput.logMessage( LogLevel.WARN, "ReportManager", From 08845963ddd8099349e92f9a1546bae6374896df Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 14 Oct 2024 14:43:47 -0700 Subject: [PATCH 08/12] test --- test/integration/dontBanSelfTest.ts | 149 ++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/test/integration/dontBanSelfTest.ts b/test/integration/dontBanSelfTest.ts index e69de29b..2684b2bd 100644 --- a/test/integration/dontBanSelfTest.ts +++ b/test/integration/dontBanSelfTest.ts @@ -0,0 +1,149 @@ +/* +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} from "@vector-im/matrix-bot-sdk"; +import {newTestUser} from "./clientHelper"; +import { strict as assert } from "assert"; +import {getFirstReaction} from "./commands/commandUtils"; + + +describe("Test: Bot doesn't ban moderation room members or ignored entities.", function () { + let client: MatrixClient; + let room: string; + let badClient: MatrixClient; + this.beforeEach(async function () { + client = await newTestUser(this.config.homeserverUrl, {name: {contains: "mod-room-test"}}); + await client.start(); + const mjolnirId = await this.mjolnir.client.getUserId(); + badClient = await newTestUser(this.config.homeserverUrl, {name: {contains: "mod-room-test-to-be-banned"}}) + const badId = await badClient.getUserId() + room = await client.createRoom({invite: [mjolnirId, badId]}); + await badClient.joinRoom(room) + await client.joinRoom(this.config.managementRoom); + await client.setUserPowerLevel(mjolnirId, room, 100); + }); + this.afterEach(async function () { + await client.stop(); + }); + + it("Properly tracks newly joined members in the moderation room", async function () { + this.timeout(20000); + + function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + await delay(4000); + const currentMods = this.mjolnir.moderators.listAll(); + let expectedCurrentMods = [await client.getUserId(), await this.mjolnir.client.getUserId()]; + expectedCurrentMods.forEach((mod) => { + if (!currentMods.includes(mod)) { + assert.fail("Expected mod not found."); + } + }); + const newMod = await newTestUser(this.config.homeserverUrl, {name: {contains: "mod-room-test"}}); + await newMod.joinRoom(this.config.managementRoom); + await delay(1000); + let updatedMods = this.mjolnir.moderators.listAll(); + if (!updatedMods.includes(await newMod.getUserId())) { + assert.fail("New moderator not found."); + } + }); + + it("Ignore command adds entities to ignore list.", async function (){ + this.timeout(20000); + + function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + const helpfulBot = await newTestUser(this.config.homeserverUrl, {name: {contains: "mod-room-test-bot"}}); + const botId = await helpfulBot.getUserId(); + + await getFirstReaction(client, this.mjolnir.managementRoomId, "✅", async () => { + return await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir ignore ${botId}`, + }); + }); + await delay(1000); + const mods = this.mjolnir.moderators.listAll() + if (!mods.includes(botId)) { + assert.fail("Bot not added to moderator list."); + } + }); + + it("Moderators and ignored entities are not banned by automatic procedures.", async function () { + this.timeout(20000); + function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir rooms add ${room}`, + }); + await getFirstReaction(client, this.mjolnir.managementRoomId, "✅", async () => { + return await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir enable BasicFloodingProtection`, + }); + }); + + for (let i = 0; i < 12; i++) { + await badClient.sendMessage(room, {msgtype: "m.text", body: "ban me"}); + } + + await delay(3000); + const badId = await badClient.getUserId(); + const badMemberEvent = await this.mjolnir.client.getRoomStateEvent(room, "m.room.member", badId) + if (badMemberEvent["membership"] !== "ban") { + assert.fail("Basic flooding protection is not working, this user should have been banned."); + } + + for (let i = 0; i < 12; i++) { + await this.mjolnir.client.sendMessage(room, {msgtype: "m.text", body: "doing mod things"}); + } + const mjolnirId = await this.mjolnir.client.getUserId(); + const mjolnirMemberEvent = await this.mjolnir.client.getRoomStateEvent(room, "m.room.member", mjolnirId); + + if (mjolnirMemberEvent["membership"] === "ban") { + assert.fail("Bot has banned itself."); + } + + const helpfulBot = await newTestUser(this.config.homeserverUrl, {name: {contains: "mod-room-test-bot"}}); + const botId = await helpfulBot.getUserId(); + + await this.mjolnir.client.inviteUser(botId, room); + await helpfulBot.joinRoom(room); + + await getFirstReaction(client, this.mjolnir.managementRoomId, "✅", async () => { + return await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir ignore ${botId}`, + }); + }); + + for (let i = 0; i < 12; i++) { + await helpfulBot.sendMessage(room, {msgtype: "m.text", body: "doing helpful bot things"}); + } + const botMemberEvent = await this.mjolnir.client.getRoomStateEvent(room, "m.room.member", botId) + + if (botMemberEvent["membership"] === "ban") { + assert.fail("Bot has banned a member of ignore list."); + } + }); +}) \ No newline at end of file From 1fb77c4901579e734d7a9f0b0085164d87ed1847 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 14 Oct 2024 14:46:42 -0700 Subject: [PATCH 09/12] lint --- src/commands/CommandHandler.ts | 2 +- test/integration/dontBanSelfTest.ts | 45 ++++++++++++++--------------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index daa47120..4c3ae17b 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -192,7 +192,7 @@ export async function handleCommand(roomId: string, event: { content: { body: st "!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 ignored - List currently ignored entities.\n" + "!mjolnir help - This menu\n"; const html = `Mjolnir help:
      ${htmlEscape(menu)}
      `; const text = `Mjolnir help:\n${menu}`; diff --git a/test/integration/dontBanSelfTest.ts b/test/integration/dontBanSelfTest.ts index 2684b2bd..d7d5d750 100644 --- a/test/integration/dontBanSelfTest.ts +++ b/test/integration/dontBanSelfTest.ts @@ -14,24 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClient} from "@vector-im/matrix-bot-sdk"; -import {newTestUser} from "./clientHelper"; +import { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { newTestUser } from "./clientHelper"; import { strict as assert } from "assert"; -import {getFirstReaction} from "./commands/commandUtils"; - +import { getFirstReaction } from "./commands/commandUtils"; describe("Test: Bot doesn't ban moderation room members or ignored entities.", function () { let client: MatrixClient; let room: string; let badClient: MatrixClient; this.beforeEach(async function () { - client = await newTestUser(this.config.homeserverUrl, {name: {contains: "mod-room-test"}}); + client = await newTestUser(this.config.homeserverUrl, { name: { contains: "mod-room-test" } }); await client.start(); const mjolnirId = await this.mjolnir.client.getUserId(); - badClient = await newTestUser(this.config.homeserverUrl, {name: {contains: "mod-room-test-to-be-banned"}}) - const badId = await badClient.getUserId() - room = await client.createRoom({invite: [mjolnirId, badId]}); - await badClient.joinRoom(room) + badClient = await newTestUser(this.config.homeserverUrl, { name: { contains: "mod-room-test-to-be-banned" } }); + const badId = await badClient.getUserId(); + room = await client.createRoom({ invite: [mjolnirId, badId] }); + await badClient.joinRoom(room); await client.joinRoom(this.config.managementRoom); await client.setUserPowerLevel(mjolnirId, room, 100); }); @@ -43,7 +42,7 @@ describe("Test: Bot doesn't ban moderation room members or ignored entities.", f this.timeout(20000); function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } await delay(4000); @@ -54,7 +53,7 @@ describe("Test: Bot doesn't ban moderation room members or ignored entities.", f assert.fail("Expected mod not found."); } }); - const newMod = await newTestUser(this.config.homeserverUrl, {name: {contains: "mod-room-test"}}); + const newMod = await newTestUser(this.config.homeserverUrl, { name: { contains: "mod-room-test" } }); await newMod.joinRoom(this.config.managementRoom); await delay(1000); let updatedMods = this.mjolnir.moderators.listAll(); @@ -63,14 +62,14 @@ describe("Test: Bot doesn't ban moderation room members or ignored entities.", f } }); - it("Ignore command adds entities to ignore list.", async function (){ + it("Ignore command adds entities to ignore list.", async function () { this.timeout(20000); function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } - const helpfulBot = await newTestUser(this.config.homeserverUrl, {name: {contains: "mod-room-test-bot"}}); + const helpfulBot = await newTestUser(this.config.homeserverUrl, { name: { contains: "mod-room-test-bot" } }); const botId = await helpfulBot.getUserId(); await getFirstReaction(client, this.mjolnir.managementRoomId, "✅", async () => { @@ -80,7 +79,7 @@ describe("Test: Bot doesn't ban moderation room members or ignored entities.", f }); }); await delay(1000); - const mods = this.mjolnir.moderators.listAll() + const mods = this.mjolnir.moderators.listAll(); if (!mods.includes(botId)) { assert.fail("Bot not added to moderator list."); } @@ -89,7 +88,7 @@ describe("Test: Bot doesn't ban moderation room members or ignored entities.", f it("Moderators and ignored entities are not banned by automatic procedures.", async function () { this.timeout(20000); function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } await client.sendMessage(this.mjolnir.managementRoomId, { @@ -104,18 +103,18 @@ describe("Test: Bot doesn't ban moderation room members or ignored entities.", f }); for (let i = 0; i < 12; i++) { - await badClient.sendMessage(room, {msgtype: "m.text", body: "ban me"}); + await badClient.sendMessage(room, { msgtype: "m.text", body: "ban me" }); } await delay(3000); const badId = await badClient.getUserId(); - const badMemberEvent = await this.mjolnir.client.getRoomStateEvent(room, "m.room.member", badId) + const badMemberEvent = await this.mjolnir.client.getRoomStateEvent(room, "m.room.member", badId); if (badMemberEvent["membership"] !== "ban") { assert.fail("Basic flooding protection is not working, this user should have been banned."); } for (let i = 0; i < 12; i++) { - await this.mjolnir.client.sendMessage(room, {msgtype: "m.text", body: "doing mod things"}); + await this.mjolnir.client.sendMessage(room, { msgtype: "m.text", body: "doing mod things" }); } const mjolnirId = await this.mjolnir.client.getUserId(); const mjolnirMemberEvent = await this.mjolnir.client.getRoomStateEvent(room, "m.room.member", mjolnirId); @@ -124,7 +123,7 @@ describe("Test: Bot doesn't ban moderation room members or ignored entities.", f assert.fail("Bot has banned itself."); } - const helpfulBot = await newTestUser(this.config.homeserverUrl, {name: {contains: "mod-room-test-bot"}}); + const helpfulBot = await newTestUser(this.config.homeserverUrl, { name: { contains: "mod-room-test-bot" } }); const botId = await helpfulBot.getUserId(); await this.mjolnir.client.inviteUser(botId, room); @@ -138,12 +137,12 @@ describe("Test: Bot doesn't ban moderation room members or ignored entities.", f }); for (let i = 0; i < 12; i++) { - await helpfulBot.sendMessage(room, {msgtype: "m.text", body: "doing helpful bot things"}); + await helpfulBot.sendMessage(room, { msgtype: "m.text", body: "doing helpful bot things" }); } - const botMemberEvent = await this.mjolnir.client.getRoomStateEvent(room, "m.room.member", botId) + const botMemberEvent = await this.mjolnir.client.getRoomStateEvent(room, "m.room.member", botId); if (botMemberEvent["membership"] === "ban") { assert.fail("Bot has banned a member of ignore list."); } }); -}) \ No newline at end of file +}); From fac9f2da537115fbe2334a17a508e386eb6ee428 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 14 Oct 2024 16:41:08 -0700 Subject: [PATCH 10/12] clear interval in cache when shutting down --- src/Mjolnir.ts | 1 + src/ModCache.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 9e8bcfa6..d421beaa 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -392,6 +392,7 @@ export class Mjolnir { this.webapis.stop(); this.reportPoller?.stop(); this.openMetrics.stop(); + this.moderators.stop(); } /** diff --git a/src/ModCache.ts b/src/ModCache.ts index 5d0a09de..d41f2ea9 100644 --- a/src/ModCache.ts +++ b/src/ModCache.ts @@ -24,6 +24,7 @@ export class ModCache { private managementRoomId: string; private ttl: number = 1000 * 60 * 60; // 60 minutes private lastInvalidation = 0; + private interval: any; constructor(client: MatrixSendClient, emitter: MatrixEmitter, managementRoomId: string) { this.client = client; @@ -38,7 +39,7 @@ export class ModCache { */ async init() { await this.populateCache(); - setInterval( + this.interval = setInterval( () => { if (Date.now() - this.lastInvalidation > this.ttl) { this.populateCache(); @@ -103,4 +104,11 @@ export class ModCache { public listAll() { return this.ignoreList.concat(this.modRoomMembers); } + + /** + * Clear the interval which refreshes cache + */ + public stop() { + clearInterval(this.interval); + } } From c7aad5ceb2c257ae74e742eb595bcf10e0533bba Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 21 Oct 2024 12:06:24 -0700 Subject: [PATCH 11/12] stop managed mjolnirs --- src/appservice/AppService.ts | 1 + src/appservice/MjolnirManager.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index 4c8f8c86..275d7c5f 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -208,6 +208,7 @@ export class MjolnirAppService { await this.dataStore.close(); await this.api.close(); this.openMetrics.stop(); + this.mjolnirManager.closeAll(); } /** diff --git a/src/appservice/MjolnirManager.ts b/src/appservice/MjolnirManager.ts index fd457d23..e0d4fe36 100644 --- a/src/appservice/MjolnirManager.ts +++ b/src/appservice/MjolnirManager.ts @@ -163,6 +163,15 @@ export class MjolnirManager { } } + /** + * Stop all the managed mjolnirs + */ + public closeAll() { + for (const mjolnir of this.perMjolnirId.values()) { + mjolnir.stop(); + } + } + /** * Utility that creates a matrix client for a virtual user on our homeserver with the specified loclapart. * @param localPart The localpart of the virtual user we need a client for. @@ -246,6 +255,13 @@ export class ManagedMjolnir { await this.mjolnir.start(); } + /** + * Stop Mjolnir from syncing and processing commands. + */ + public stop() { + this.mjolnir.stop(); + } + public async getUserId(): Promise { return await this.mjolnir.client.getUserId(); } From 92c9ca61cb6675ab211027dd578d045ba307c135 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Mon, 21 Oct 2024 12:29:27 -0700 Subject: [PATCH 12/12] don't join user to ban to management room --- test/integration/standardConsequenceTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/standardConsequenceTest.ts b/test/integration/standardConsequenceTest.ts index eea8d636..b83b3be8 100644 --- a/test/integration/standardConsequenceTest.ts +++ b/test/integration/standardConsequenceTest.ts @@ -65,7 +65,7 @@ describe("Test: standard consequences", function () { this.timeout(20000); let protectedRoomId = await this.mjolnir.client.createRoom({ invite: [await badUser.getUserId()] }); - await badUser.joinRoom(this.mjolnir.managementRoomId); + await goodUser.joinRoom(this.mjolnir.managementRoomId); await badUser.joinRoom(protectedRoomId); await this.mjolnir.addProtectedRoom(protectedRoomId); @@ -96,7 +96,7 @@ describe("Test: standard consequences", function () { ban = event; } }); - badUser.on("room.event", (roomId, event) => { + goodUser.on("room.event", (roomId, event) => { if ( roomId === this.mjolnir.managementRoomId && event?.type === "m.room.message" &&