diff --git a/.well-known/funding-manifest-urls b/.well-known/funding-manifest-urls new file mode 100644 index 00000000..1b14b420 --- /dev/null +++ b/.well-known/funding-manifest-urls @@ -0,0 +1 @@ +https://matrix.org/funding.json diff --git a/README.md b/README.md index d382cb0a..e54823af 100644 --- a/README.md +++ b/README.md @@ -70,56 +70,4 @@ server can only receive requests from your reverse proxy (e.g. `localhost`). ## Development -TODO. It's a TypeScript project with a linter. - -### Development and testing with mx-tester - -WARNING: mx-tester is currently work in progress, but it can still save you some time and is better than struggling with nothing. - -If you have docker installed you can quickly get setup with a development environment by using -[mx-tester](https://github.com/matrix-org/mx-tester). - -To use mx-tester you will need to have rust installed. You can do that at [rustup](https://rustup.rs/) or [here](https://rust-lang.github.io/rustup/installation/other.html), you should probably also check your distro's documentation first to see if they have specific instructions for installing rust. - -Once rust is installed you can install mx-tester like so. - -``` -$ cargo install mx-tester -``` - -Once you have mx-tester installed you we will want to build a synapse image with synapse_antispam from the Mjolnir project root. - -``` -$ mx-tester build -``` - -Then we can start a container that uses that image and the config in `mx-tester.yml`. - -``` -$ mx-tester up -``` - -Once you have called `mx-tester up` you can run the integration tests. -``` -$ yarn test:integration -``` - -After calling `mx-tester up`, if we want to play with mojlnir locally we can run the following and then point a matrix client to http://localhost:9999. -You should then be able to join the management room at `#moderators:localhost:9999`. - -``` -yarn test:manual -``` - -Once we are finished developing we can stop the synapse container. - -``` -mx-tester down -``` - -### Running integration tests - -The integration tests can be run with `yarn test:integration`. -The config that the tests use is in `config/harness.yaml` -and by default this is configured to work with the server specified in `mx-tester.yml`, -but you can configure it however you like to run against your own setup. +See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/config/default.yaml b/config/default.yaml index 610bc717..35c5d585 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -275,4 +275,14 @@ displayReports: true # How sensitive the NsfwProtection should be, which determines if an image should be redacted. A number between 0 - .99, # with a lower number indicating greater sensitivity, possibly resulting in images being more aggressively flagged # and redacted as NSFW -nsfwSensitivity: .6 \ No newline at end of file +nsfwSensitivity: .6 + +# Set this to true if the synapse this mjolnir is protecting is using Matrix Authentication Service for auth +# If so, provide the base url and clientId + clientSecret needed to obtain a token from MAS - see +# https://element-hq.github.io/matrix-authentication-service/index.html for more information about +# configuring MAS clients/authorization grants +#MAS: +# use: true +# url: "https://auth.your-auth.com" +# clientId: 'SOMEID' +# clientSecret: 'SoMEseCreT' diff --git a/package.json b/package.json index f6a3e6a2..11c63223 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mjolnir", - "version": "1.9.2", + "version": "1.10.0", "description": "A moderation tool for Matrix", "main": "lib/index.js", "repository": "git@github.com:matrix-org/mjolnir.git", @@ -34,6 +34,7 @@ "@types/pg": "^8.6.5", "@types/request": "^2.48.8", "@types/shell-quote": "1.7.1", + "@types/simple-oauth2": "^5.0.7", "crypto-js": "^4.2.0", "eslint": "^7.32", "expect": "^27.0.6", @@ -49,7 +50,7 @@ "@tensorflow/tfjs-node": "^4.21.0", "@vector-im/matrix-bot-sdk": "^0.7.1-element.6", "await-lock": "^2.2.2", - "axios": "^1.7.6", + "axios": "^1.8.2", "body-parser": "^1.20.1", "config": "^3.3.8", "express": "^4.20", @@ -58,18 +59,17 @@ "humanize-duration-ts": "^2.1.1", "js-yaml": "^4.1.0", "jsdom": "^16.6.0", - "lru-cache": "^11.0.1", "matrix-appservice-bridge": "10.3.1", "nsfwjs": "^4.1.0", - "parse-duration": "^1.0.2", + "parse-duration": "^2.1.3", "pg": "^8.8.0", "prom-client": "^14.1.0", "shell-quote": "^1.7.3", + "simple-oauth2": "^5.1.0", "ulidx": "^0.3.0", "yaml": "^2.2.2" }, "engines": { "node": ">=20.0.0" - }, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + } } diff --git a/src/MASClient.ts b/src/MASClient.ts new file mode 100644 index 00000000..6d58046c --- /dev/null +++ b/src/MASClient.ts @@ -0,0 +1,132 @@ +/* +Copyright 2025 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 { ClientCredentials, AccessToken } from "simple-oauth2"; +import { IConfig } from "./config"; +import axios from "axios"; +import { LogService } from "@vector-im/matrix-bot-sdk"; + +export class MASClient { + public readonly config: IConfig; + private client: ClientCredentials; + private accessToken: AccessToken; + + constructor(config: IConfig) { + LogService.info("MAS client", "Setting up MAS client"); + this.config = config; + const clientConfig = { + client: { + id: config.MAS.clientId, + secret: config.MAS.clientSecret, + }, + auth: { + tokenPath: config.MAS.url + "/oauth2/token", + tokenHost: config.MAS.url, + }, + }; + this.client = new ClientCredentials(clientConfig); + } + + public async getAccessToken() { + if (!this.accessToken || this.accessToken.expired()) { + // fetch a new one + const tokenParams = { scope: "urn:mas:admin" }; + try { + this.accessToken = await this.client.getToken(tokenParams); + } catch (error) { + LogService.error("MAS client", "Error fetching auth token for MAS:", error.message); + throw error; + } + } + return this.accessToken; + } + + public async getMASUserId(userId: string): Promise { + const index = userId.indexOf(":"); + const localpart = userId.substring(1, index); + + try { + const resp = await this.doRequest("get", `/api/admin/v1/users/by-username/${localpart}`); + return resp.data.id; + } catch (error) { + LogService.error("MAS client", `Error fetching MAS id for user ${userId}:`, error.message); + throw error; + } + } + + public async deactivateMASUser(userId: string): Promise { + const MASId = await this.getMASUserId(userId); + try { + await this.doRequest("post", `/api/admin/v1/users/${MASId}/deactivate`); + } catch (error) { + LogService.error("MAS client", `Error deactivating user ${userId} via MAS`, error.message); + throw error; + } + } + + public async lockMASUser(userId: string): Promise { + const MASId = await this.getMASUserId(userId); + try { + await this.doRequest("post", `/api/admin/v1/users/${MASId}/lock`); + } catch (error) { + LogService.error("MAS client", `Error locking user ${userId} via MAS:`, error.message); + throw error; + } + } + + public async unlockMASUser(userId: string): Promise { + const MASId = await this.getMASUserId(userId); + try { + await this.doRequest("post", `/api/admin/v1/users/${MASId}/unlock`); + } catch (error) { + LogService.error("MAS client", `Error unlocking user ${userId} via MAS:`, error.message); + throw error; + } + } + + public async UserIsMASAdmin(userId: string): Promise { + const index = userId.indexOf(":"); + const localpart = userId.substring(1, index); + const path = `/api/admin/v1/users/by-username/${localpart}`; + + let resp; + try { + resp = await this.doRequest("get", path); + } catch (error) { + LogService.error("MAS client", `Error determining if MAS user ${userId} is admin: `, error.message); + throw error; + } + return resp.data.attributes.admin; + } + + public async doRequest(method: string, path: string) { + const url = this.config.MAS.url + path; + const accessToken = await this.getAccessToken(); + const headers = { + "User-Agent": "Mjolnir", + "Content-Type": "application/json; charset=UTF-8", + "Authorization": `Bearer ${accessToken.token.access_token}`, + }; + LogService.info("MAS client", `Calling ${url}`); + + const resp = await axios({ + method, + url, + headers, + }); + return resp.data; + } +} diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 1a19de7a..2bf17637 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -43,6 +43,7 @@ import { MatrixEmitter, MatrixSendClient } from "./MatrixEmitter"; import { OpenMetrics } from "./webapis/OpenMetrics"; import { LRUCache } from "lru-cache"; import { ModCache } from "./ModCache"; +import { MASClient } from "./MASClient"; export const STATE_NOT_STARTED = "not_started"; export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; @@ -100,6 +101,16 @@ export class Mjolnir { */ public moderators: ModCache; + /** + * Whether the Synapse Mjolnir is protecting uses the Matrix Authentication Service + */ + public readonly usingMAS: boolean; + + /** + * Client for making calls to MAS (if using) + */ + public MASClient: MASClient; + /** * Adds a listener to the client that will automatically accept invitations. * @param {MatrixSendClient} client @@ -214,6 +225,11 @@ export class Mjolnir { this.protectedRoomsConfig = new ProtectedRoomsConfig(client); this.policyListManager = new PolicyListManager(this); + if (config.MAS.use) { + this.usingMAS = true; + this.MASClient = new MASClient(config); + } + // Setup bot. matrixEmitter.on("room.event", this.handleEvent.bind(this)); @@ -563,9 +579,13 @@ export class Mjolnir { public async isSynapseAdmin(): Promise { try { - const endpoint = `/_synapse/admin/v1/users/${await this.client.getUserId()}/admin`; - const response = await this.client.doRequest("GET", endpoint); - return response["admin"]; + if (this.usingMAS) { + return await this.MASClient.UserIsMASAdmin(this.clientUserId); + } else { + const endpoint = `/_synapse/admin/v1/users/${await this.client.getUserId()}/admin`; + const response = await this.client.doRequest("GET", endpoint); + return response["admin"]; + } } catch (e) { LogService.error("Mjolnir", "Error determining if Mjolnir is a server admin:"); LogService.error("Mjolnir", extractRequestError(e)); @@ -590,12 +610,28 @@ export class Mjolnir { return await this.client.doRequest("PUT", endpoint, null, body); } + public async lockSynapseUser(userId: string): Promise { + const endpoint = `/_synapse/admin/v2/users/${userId}`; + const body = { locked: true }; + return await this.client.doRequest("PUT", endpoint, null, body); + } + + public async unlockSynapseUser(userId: string): Promise { + const endpoint = `/_synapse/admin/v2/users/${userId}`; + const body = { locked: false }; + return await this.client.doRequest("PUT", endpoint, null, body); + } + public async shutdownSynapseRoom(roomId: string, message?: string): Promise { + message = + message ?? + "A room you were invited to or participating in is not permitted on this server and has been removed. This room is a notification of that change - you may safely leave this room."; const endpoint = `/_synapse/admin/v1/rooms/${roomId}`; return await this.client.doRequest("DELETE", endpoint, null, { new_room_user_id: await this.client.getUserId(), block: true, - message: message /* If `undefined`, we'll use Synapse's default message. */, + message: message, + room_name: "Room removed", }); } diff --git a/src/ModCache.ts b/src/ModCache.ts index d41f2ea9..f5dcc4d5 100644 --- a/src/ModCache.ts +++ b/src/ModCache.ts @@ -56,21 +56,16 @@ export class ModCache { } /** - * Populate the cache by fetching moderation room membership events + * Populate the cache by fetching moderation room members */ public async populateCache() { - const memberEvents = await this.client.getRoomMembers( - this.managementRoomId, - undefined, - ["join", "invite"], - ["ban", "leave"], - ); + const members = await this.client.getJoinedRoomMembers(this.managementRoomId); this.modRoomMembers = []; - memberEvents.forEach((event) => { - if (!this.modRoomMembers.includes(event.stateKey)) { - this.modRoomMembers.push(event.stateKey); + members.forEach((member) => { + if (!this.modRoomMembers.includes(member)) { + this.modRoomMembers.push(member); } - const server = event.stateKey.split(":")[1]; + const server = member.split(":")[1]; if (!this.modRoomMembers.includes(server)) { this.modRoomMembers.push(server); } diff --git a/src/ProtectedRoomsSet.ts b/src/ProtectedRoomsSet.ts index 9b8b345b..656f3100 100644 --- a/src/ProtectedRoomsSet.ts +++ b/src/ProtectedRoomsSet.ts @@ -362,12 +362,12 @@ export class ProtectedRoomsSet { // ignore - assume no ACL } - await this.client.sendMessage(this.managementRoomId, { - msgtype: "m.text", - body: `Applying ACL in ${roomId}.`, - format: "org.matrix.custom.html", - formatted_body: `Applying ACL in ${htmlEscape(roomId)}.`, - }); + await this.managementRoomOutput.logMessage( + LogLevel.DEBUG, + "ApplyAcl", + `Applying ACL in ${roomId}`, + roomId, + ); if (!this.config.noop) { await this.client.sendStateEvent(roomId, "m.room.server_acl", "", finalAcl); diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 3271df66..cc3037fc 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -51,6 +51,8 @@ import { execSetupProtectedRoom } from "./SetupDecentralizedReportingCommand"; import { execSuspendCommand } from "./SuspendCommand"; import { execUnsuspendCommand } from "./UnsuspendCommand"; import { execIgnoreCommand, execListIgnoredCommand } from "./IgnoreCommand"; +import { execLockCommand } from "./LockCommand"; +import { execUnlockCommand } from "./UnlockCommand"; export const COMMAND_PREFIX = "!mjolnir"; @@ -146,6 +148,10 @@ 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 if (parts[1] === "lock") { + return await execLockCommand(roomId, event, mjolnir, parts); + } else if (parts[1] === "unlock") { + return await execUnlockCommand(roomId, event, mjolnir, parts); } else if (parts[1] === "help") { // Help menu const protectionMenu = @@ -172,6 +178,8 @@ export async function handleCommand(roomId: string, event: { content: { body: st "!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 lock - Lock the account of the specified user\n" + + "!mjolnir unlock - Unlock the account of 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"; diff --git a/src/commands/DeactivateCommand.ts b/src/commands/DeactivateCommand.ts index 6b97a980..ebeca3e5 100644 --- a/src/commands/DeactivateCommand.ts +++ b/src/commands/DeactivateCommand.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 deactivate export async function execDeactivateCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { @@ -30,6 +30,20 @@ export async function execDeactivateCommand(roomId: string, event: any, mjolnir: return; } - await mjolnir.deactivateSynapseUser(target); + if (mjolnir.usingMAS) { + try { + await mjolnir.MASClient.deactivateMASUser(target); + } catch (err) { + mjolnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "Deactivate Command", + `There was an error deactivating ${target}, please check the logs for more information.`, + ); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "❌"); + return; + } + } else { + await mjolnir.deactivateSynapseUser(target); + } await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); } diff --git a/src/commands/LockCommand.ts b/src/commands/LockCommand.ts new file mode 100644 index 00000000..87ad46a2 --- /dev/null +++ b/src/commands/LockCommand.ts @@ -0,0 +1,49 @@ +/* +Copyright 2025 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 lock +export async function execLockCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + const target = parts[2]; + + const isAdmin = await mjolnir.isSynapseAdmin(); + if (!isAdmin) { + const message = "I am not a Synapse administrator, or the endpoint is blocked"; + const reply = RichReply.createFor(roomId, event, message, message); + reply["msgtype"] = "m.notice"; + mjolnir.client.sendMessage(roomId, reply); + return; + } + + if (mjolnir.usingMAS) { + try { + await mjolnir.MASClient.lockMASUser(target); + } catch (err) { + mjolnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "Lock Command", + `There was an error locking ${target}, please check the logs for more information.`, + ); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "❌"); + return; + } + } else { + await mjolnir.lockSynapseUser(target); + } + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); +} diff --git a/src/commands/RedactCommand.ts b/src/commands/RedactCommand.ts index 9a7c817d..ced998ab 100644 --- a/src/commands/RedactCommand.ts +++ b/src/commands/RedactCommand.ts @@ -51,8 +51,7 @@ export async function execRedactCommand(roomId: string, event: any, mjolnir: Mjo } const targetRoomIds = targetRoom ? [targetRoom] : mjolnir.protectedRoomsTracker.getProtectedRooms(); - const isAdmin = await mjolnir.isSynapseAdmin(); - await redactUserMessagesIn(mjolnir.client, mjolnir.managementRoomOutput, userId, targetRoomIds, isAdmin, limit); + await redactUserMessagesIn(mjolnir.client, mjolnir.managementRoomOutput, userId, targetRoomIds, false, limit); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); await mjolnir.client.redactEvent(roomId, processingReactionId, "done processing"); diff --git a/src/commands/SinceCommand.ts b/src/commands/SinceCommand.ts index 5c25f0a8..6b335f72 100644 --- a/src/commands/SinceCommand.ts +++ b/src/commands/SinceCommand.ts @@ -16,10 +16,11 @@ limitations under the License. import { Mjolnir } from "../Mjolnir"; import { LogLevel, LogService, RichReply } from "@vector-im/matrix-bot-sdk"; -import { htmlEscape, parseDuration } from "../utils"; +import { htmlEscape } from "../utils"; import { ParseEntry } from "shell-quote"; import { HumanizeDurationLanguage, HumanizeDuration } from "humanize-duration-ts"; import { Join } from "../RoomMembers"; +import parse from "parse-duration"; const HUMANIZE_LAG_SERVICE: HumanizeDurationLanguage = new HumanizeDurationLanguage(); const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE); @@ -143,14 +144,14 @@ async function execSinceCommandAux( const minDateResult = parseToken("/", dateOrDurationToken, (source) => { // Attempt to parse `/` as a date. let maybeMinDate = new Date(source); - let maybeMaxAgeMS = (Date.now() - maybeMinDate.getTime()) as number; + let maybeMaxAgeMS: number | null = (Date.now() - maybeMinDate.getTime()) as number; if (!Number.isNaN(maybeMaxAgeMS)) { return { ok: { minDate: maybeMinDate, maxAgeMS: maybeMaxAgeMS } }; } //...or as a duration - maybeMaxAgeMS = parseDuration(source); - if (maybeMaxAgeMS && !Number.isNaN(maybeMaxAgeMS)) { + maybeMaxAgeMS = parse(source); + if (maybeMaxAgeMS) { maybeMaxAgeMS = Math.abs(maybeMaxAgeMS); return { ok: { minDate: new Date(Date.now() - maybeMaxAgeMS), maxAgeMS: maybeMaxAgeMS } }; } diff --git a/src/commands/StatusCommand.ts b/src/commands/StatusCommand.ts index ce0fadbe..49edf979 100644 --- a/src/commands/StatusCommand.ts +++ b/src/commands/StatusCommand.ts @@ -16,9 +16,10 @@ limitations under the License. import { Mjolnir, STATE_CHECKING_PERMISSIONS, STATE_NOT_STARTED, STATE_RUNNING, STATE_SYNCING } from "../Mjolnir"; import { RichReply } from "@vector-im/matrix-bot-sdk"; -import { htmlEscape, parseDuration } from "../utils"; +import { htmlEscape } from "../utils"; import { HumanizeDurationLanguage, HumanizeDuration } from "humanize-duration-ts"; import PolicyList from "../models/PolicyList"; +import parse from "parse-duration"; const HUMANIZE_LAG_SERVICE: HumanizeDurationLanguage = new HumanizeDurationLanguage(); const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE); @@ -136,7 +137,7 @@ async function showJoinsStatus(destinationRoomId: string, event: any, mjolnir: M text: "Missing arg: `room id`", }; } - const maxAgeMS = parseDuration(maxAgeArg); + const maxAgeMS = parse(maxAgeArg); if (!maxAgeMS) { return { html: "Invalid duration. Example: 1.5 days or 10 minutes", diff --git a/src/commands/UnbanBanCommand.ts b/src/commands/UnbanBanCommand.ts index 66d2a86a..978817fa 100644 --- a/src/commands/UnbanBanCommand.ts +++ b/src/commands/UnbanBanCommand.ts @@ -127,7 +127,7 @@ export async function execBanCommand(roomId: string, event: any, mjolnir: Mjolni const matcher = new MatrixGlob(bits.entity); const moderators = mjolnir.moderators.listAll(); - moderators.forEach(async (name) => { + for (const name of moderators) { if (matcher.test(name)) { await mjolnir.managementRoomOutput.logMessage( LogLevel.ERROR, @@ -136,7 +136,7 @@ export async function execBanCommand(roomId: string, event: any, mjolnir: Mjolni ); return; } - }); + } await bits.list!.banEntity(bits.ruleType!, bits.entity, bits.reason); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); diff --git a/src/commands/UnlockCommand.ts b/src/commands/UnlockCommand.ts new file mode 100644 index 00000000..e7db76a6 --- /dev/null +++ b/src/commands/UnlockCommand.ts @@ -0,0 +1,49 @@ +/* +Copyright 2025 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 unlock +export async function execUnlockCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + const target = parts[2]; + + const isAdmin = await mjolnir.isSynapseAdmin(); + if (!isAdmin) { + const message = "I am not a Synapse administrator, or the endpoint is blocked"; + const reply = RichReply.createFor(roomId, event, message, message); + reply["msgtype"] = "m.notice"; + mjolnir.client.sendMessage(roomId, reply); + return; + } + + if (mjolnir.usingMAS) { + try { + await mjolnir.MASClient.unlockMASUser(target); + } catch (err) { + mjolnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "Unlock Command", + `There was an error unlocking ${target}, please check the logs for more information.`, + ); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "❌"); + return; + } + } else { + await mjolnir.unlockSynapseUser(target); + } + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅"); +} diff --git a/src/config.ts b/src/config.ts index 8b965ecf..0b30adf0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -75,6 +75,12 @@ export interface IConfig { username: string; password: string; }; + MAS: { + use: boolean; + url: string; + clientId: string; + clientSecret: string; + }; pantalaimon: { use: boolean; username: string; @@ -200,6 +206,12 @@ const defaultConfig: IConfig = { username: "name", password: "pass", }, + MAS: { + use: false, + url: "", + clientId: "", + clientSecret: "", + }, pantalaimon: { use: false, username: "", diff --git a/src/index.ts b/src/index.ts index f63690f4..80c3368b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ import { initializeSentry, initializeGlobalPerformanceMetrics, patchMatrixClient LogService.setLogger(new RichConsoleLogger()); LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG)); + LogService.muteModule("MatrixClientLite"); LogService.info("index", "Starting bot..."); diff --git a/src/models/AccessControlUnit.ts b/src/models/AccessControlUnit.ts index 6ce479b6..0a167f19 100644 --- a/src/models/AccessControlUnit.ts +++ b/src/models/AccessControlUnit.ts @@ -170,6 +170,7 @@ class ListRuleCache { * @param change The change made to a rule that was present in the policy list. */ private updateCacheForChange(change: ListRuleChange): void { + LogService.debug("ACU: updateCacheForChange", change.event); if (change.rule.kind !== this.entityType || change.rule.recommendation !== this.recommendation) { return; } diff --git a/src/protections/MentionSpam.ts b/src/protections/MentionSpam.ts index 7fdace4b..31f694b0 100644 --- a/src/protections/MentionSpam.ts +++ b/src/protections/MentionSpam.ts @@ -16,18 +16,12 @@ limitations under the License. import { Protection } from "./IProtection"; import { Mjolnir } from "../Mjolnir"; -import { LogLevel, Permalinks, UserID } from "@vector-im/matrix-bot-sdk"; +import { LogLevel, LogService, Permalinks, UserID } from "@vector-im/matrix-bot-sdk"; import { NumberProtectionSetting } from "./ProtectionSettings"; -import { LRUCache } from "lru-cache"; export const DEFAULT_MAX_MENTIONS = 10; export class MentionSpam extends Protection { - private roomDisplaynameCache = new LRUCache({ - ttl: 1000 * 60 * 24, // 24 minutes - ttlAutopurge: true, - }); - settings = { maxMentions: new NumberProtectionSetting(DEFAULT_MAX_MENTIONS, 1), }; @@ -43,24 +37,6 @@ export class MentionSpam extends Protection { return "If a user posts many mentions, that message is redacted. No bans are issued."; } - private async getRoomDisplaynames(mjolnir: Mjolnir, roomId: string): Promise { - const existing = this.roomDisplaynameCache.get(roomId); - if (existing) { - return existing; - } - const profiles = await mjolnir.client.getJoinedRoomMembersWithProfiles(roomId); - const displaynames = ( - Object.values(profiles) - .map((v) => v.display_name?.toLowerCase()) - .filter((v) => typeof v === "string") as string[] - ) - // Limit to displaynames with more than a few characters. - .filter((displayname) => displayname.length > 2); - - this.roomDisplaynameCache.set(roomId, displaynames); - return displaynames; - } - public checkMentions( body: unknown | undefined, htmlBody: unknown | undefined, @@ -79,35 +55,11 @@ export class MentionSpam extends Protection { return false; } - public checkDisplaynameMentions( - body: unknown | undefined, - htmlBody: unknown | undefined, - displaynames: string[], - ): boolean { - const max = this.settings.maxMentions.value; - const bodyWords = ((typeof body === "string" && body) || "").toLowerCase(); - if (displaynames.filter((s) => bodyWords.includes(s.toLowerCase())).length > max) { - return true; - } - const htmlBodyWords = decodeURIComponent((typeof htmlBody === "string" && htmlBody) || "").toLowerCase(); - if (displaynames.filter((s) => htmlBodyWords.includes(s)).length > max) { - return true; - } - return false; - } - public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { if (event["type"] === "m.room.message") { let content = event["content"] || {}; const explicitMentions = content["m.mentions"]?.user_ids; - let hitLimit = this.checkMentions(content.body, content.formatted_body, explicitMentions); - - // Slightly more costly to hit displaynames, so only do it if we don't hit on mxid matches. - if (!hitLimit) { - const displaynames = await this.getRoomDisplaynames(mjolnir, roomId); - hitLimit = this.checkDisplaynameMentions(content.body, content.formatted_body, displaynames); - } - + const hitLimit = this.checkMentions(content.body, content.formatted_body, explicitMentions); if (hitLimit) { await mjolnir.managementRoomOutput.logMessage( LogLevel.WARN, @@ -117,6 +69,10 @@ export class MentionSpam extends Protection { // Redact the event if (!mjolnir.config.noop) { await mjolnir.client.redactEvent(roomId, event["event_id"], "Message was detected as spam."); + LogService.info( + "MentionSpam", + `Redacting event content ${JSON.stringify(content)} for spamming mentions.`, + ); mjolnir.unlistedUserRedactionHandler.addUser(event["sender"]); } else { await mjolnir.managementRoomOutput.logMessage( diff --git a/src/protections/MessageIsVideo.ts b/src/protections/MessageIsVideo.ts new file mode 100644 index 00000000..f0a9ef15 --- /dev/null +++ b/src/protections/MessageIsVideo.ts @@ -0,0 +1,64 @@ +/* +Copyright 2025 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 { Protection } from "./IProtection"; +import { Mjolnir } from "../Mjolnir"; +import { LogLevel, Permalinks, UserID } from "@vector-im/matrix-bot-sdk"; + +export class MessageIsVideo extends Protection { + settings = {}; + + constructor() { + super(); + } + + public get name(): string { + return "MessageIsVideoProtection"; + } + public get description(): string { + return "If a user posts a video, that message will be redacted. No bans are issued."; + } + + public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { + if (event["type"] === "m.room.message") { + let content = event["content"] || {}; + const relation = content["m.relates_to"]; + if (relation?.["rel_type"] === "m.replace") { + content = content?.["m.new_content"] ?? content; + } + const msgtype = content["msgtype"] || "m.text"; + const isVideo = msgtype === "m.video"; + if (isVideo) { + await mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "MessageIsVideo", + `Redacting event from ${event["sender"]} for posting an video. ${Permalinks.forEvent(roomId, event["event_id"], [new UserID(await mjolnir.client.getUserId()).domain])}`, + ); + // Redact the event + if (!mjolnir.config.noop) { + await mjolnir.client.redactEvent(roomId, event["event_id"], "Videos are not permitted here"); + } else { + await mjolnir.managementRoomOutput.logMessage( + LogLevel.WARN, + "MessageIsVideo", + `Tried to redact ${event["event_id"]} in ${roomId} but Mjolnir is running in no-op mode`, + roomId, + ); + } + } + } + } +} diff --git a/src/protections/NsfwProtection.ts b/src/protections/NsfwProtection.ts index a6f45b20..6013e543 100644 --- a/src/protections/NsfwProtection.ts +++ b/src/protections/NsfwProtection.ts @@ -45,71 +45,73 @@ export class NsfwProtection extends Protection { } public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { - if (event["type"] === "m.room.message") { - let content = JSON.stringify(event["content"]); - if (!content.toLowerCase().includes("mxc")) { - return; - } - // try and grab a human-readable alias for more helpful management room output - const maybeAlias = await mjolnir.client.getPublishedAlias(roomId); - const room = maybeAlias ? maybeAlias : roomId; - - const mxcs = content.match(/(mxc?:\/\/[^\s'"]+)/gim); - if (!mxcs) { - //something's gone wrong with the regex - await mjolnir.managementRoomOutput.logMessage( - LogLevel.ERROR, - "NSFWProtection", - `Unable to find any mxcs in ${event["event_id"]} in ${room}`, - ); - return; - } + if (event.type !== "m.room.message" && event.type !== "m.sticker") { + return; + } - // @ts-ignore - see null check immediately above - for (const mxc of mxcs) { - const image = await mjolnir.client.downloadContent(mxc); + const content = JSON.stringify(event.content); + const mxcs = content.match(/(mxc:\/\/[^\s'"]+)/gim); + if (!mxcs) { + return; + } + // try and grab a human-readable alias for more helpful management room output + const maybeAlias = await mjolnir.client.getPublishedAlias(roomId); + const room = maybeAlias ? maybeAlias : roomId; - let decodedImage; - try { - decodedImage = await node.decodeImage(image.data, 3); - } catch (e) { - LogService.error("NsfwProtection", `There was an error processing an image: ${e}`); - continue; - } + // Skip classification if sensitivity is 0, as it's a waste of resources + // We are using 0.0001 as a threshold to avoid floating point errors + if (mjolnir.config.nsfwSensitivity <= 0.0001) { + await this.redactEvent(mjolnir, roomId, event, room); + return; + } + + for (const mxc of mxcs) { + const image = await mjolnir.client.downloadContent(mxc); - const predictions = await this.model.classify(decodedImage); - - for (const prediction of predictions) { - if (["Hentai", "Porn"].includes(prediction["className"])) { - if (prediction["probability"] > mjolnir.config.nsfwSensitivity) { - try { - await mjolnir.client.redactEvent(roomId, event["event_id"]); - } catch (err) { - await mjolnir.managementRoomOutput.logMessage( - LogLevel.ERROR, - "NSFWProtection", - `There was an error redacting ${event["event_id"]} in ${room}: ${err}`, - ); - } - let eventId = event["event_id"]; - let body = `Redacted an image in ${room} ${eventId}`; - let formatted_body = `
- Redacted an image in ${room} -
${eventId}
${room}
-
`; - const msg = { - msgtype: "m.notice", - body: body, - format: "org.matrix.custom.html", - formatted_body: formatted_body, - }; - await mjolnir.client.sendMessage(mjolnir.managementRoomId, msg); - break; - } + let decodedImage; + try { + decodedImage = await node.decodeImage(image.data, 3); + } catch (e) { + LogService.error("NsfwProtection", `There was an error processing an image: ${e}`); + continue; + } + + const predictions = await this.model.classify(decodedImage); + + for (const prediction of predictions) { + if (["Hentai", "Porn"].includes(prediction["className"])) { + if (prediction["probability"] > mjolnir.config.nsfwSensitivity) { + await this.redactEvent(mjolnir, roomId, event, room); + break; } } - decodedImage.dispose(); } + decodedImage.dispose(); + } + } + + private async redactEvent(mjolnir: Mjolnir, roomId: string, event: any, room: string): Promise { + try { + await mjolnir.client.redactEvent(roomId, event["event_id"]); + } catch (err) { + await mjolnir.managementRoomOutput.logMessage( + LogLevel.ERROR, + "NSFWProtection", + `There was an error redacting ${event["event_id"]} in ${room}: ${err}`, + ); } + let eventId = event["event_id"]; + let body = `Redacted an image in ${room} ${eventId}`; + let formatted_body = `
+ Redacted an image in ${room} +
${eventId}
${room}
+
`; + const msg = { + msgtype: "m.notice", + body: body, + format: "org.matrix.custom.html", + formatted_body: formatted_body, + }; + await mjolnir.client.sendMessage(mjolnir.managementRoomId, msg); } } diff --git a/src/protections/ProtectionManager.ts b/src/protections/ProtectionManager.ts index 485f05e1..76fc1548 100644 --- a/src/protections/ProtectionManager.ts +++ b/src/protections/ProtectionManager.ts @@ -33,6 +33,7 @@ import { RoomUpdateError } from "../models/RoomUpdateError"; import { LocalAbuseReports } from "./LocalAbuseReports"; import { NsfwProtection } from "./NsfwProtection"; import { MentionSpam } from "./MentionSpam"; +import { MessageIsVideo } from "./MessageIsVideo"; const PROTECTIONS: Protection[] = [ new FirstMessageIsImage(), @@ -46,6 +47,7 @@ const PROTECTIONS: Protection[] = [ new LocalAbuseReports(), new NsfwProtection(), new MentionSpam(), + new MessageIsVideo(), ]; const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections"; diff --git a/src/protections/ProtectionSettings.ts b/src/protections/ProtectionSettings.ts index f95e7697..065b7624 100644 --- a/src/protections/ProtectionSettings.ts +++ b/src/protections/ProtectionSettings.ts @@ -15,15 +15,7 @@ limitations under the License. */ import { EventEmitter } from "events"; -import { default as parseDuration } from "parse-duration"; - -// Define a few aliases to simplify parsing durations. - -parseDuration["milliseconds"] = parseDuration["millis"] = parseDuration["ms"]; -parseDuration["days"] = parseDuration["day"]; -parseDuration["weeks"] = parseDuration["week"] = parseDuration["wk"]; -parseDuration["months"] = parseDuration["month"]; -parseDuration["years"] = parseDuration["year"]; +import parse from "parse-duration"; export class ProtectionSettingValidationError extends Error {} @@ -170,8 +162,8 @@ export class DurationMSProtectionSetting extends AbstractProtectionSetting]/g, @@ -83,7 +72,7 @@ async function adminRedactUserMessagesIn( ) { const body = { limit: limit, rooms: targetRooms }; const redactEndpoint = `/_synapse/admin/v1/user/${userId}/redact`; - const response = await client.doRequest("GET", redactEndpoint, null, body); + const response = await client.doRequest("POST", redactEndpoint, null, body); const redactID = response["redact_id"]; await managementRoom.logMessage( LogLevel.INFO, diff --git a/synapse_antispam/setup.py b/synapse_antispam/setup.py index bfe0203b..9c37cd0a 100644 --- a/synapse_antispam/setup.py +++ b/synapse_antispam/setup.py @@ -2,7 +2,7 @@ setup( name="mjolnir", - version="1.9.2", # version automated in package.json - Do not edit this line, use `yarn version`. + version="1.10.0", # version automated in package.json - Do not edit this line, use `yarn version`. packages=find_packages(), description="Mjolnir Antispam", include_package_data=True, diff --git a/test/integration/MessageIsVideo.ts b/test/integration/MessageIsVideo.ts new file mode 100644 index 00000000..028f4b60 --- /dev/null +++ b/test/integration/MessageIsVideo.ts @@ -0,0 +1,95 @@ +/* +Copyright 2025 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 { getFirstReaction } from "./commands/commandUtils"; +import { readFileSync } from "fs"; +import { strict as assert } from "assert"; + +describe("Test: Message is video", function () { + let client: MatrixClient; + let testRoom: string; + this.beforeEach(async function () { + client = await newTestUser(this.config.homeserverUrl, { name: { contains: "message-is-video" } }); + await client.start(); + const mjolnirId = await this.mjolnir.client.getUserId(); + testRoom = await client.createRoom({ invite: [mjolnirId] }); + await client.joinRoom(testRoom); + await client.joinRoom(this.config.managementRoom); + await client.setUserPowerLevel(mjolnirId, testRoom, 100); + }); + this.afterEach(async function () { + await getFirstReaction(client, this.mjolnir.managementRoomId, "✅", async () => { + return await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir disable MessageIsVideoProtection`, + }); + }); + await client.stop(); + }); + + function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + it("Redacts all messages that are video msgtype if protection enabled", async function () { + this.timeout(20000); + + await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir rooms add ${testRoom}`, + }); + await getFirstReaction(client, this.mjolnir.managementRoomId, "✅", async () => { + return await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir enable MessageIsVideoProtection`, + }); + }); + const data = readFileSync("test_tree.jpg"); + const mxc = await client.uploadContent(data, "image/png"); + + // use a bogus mxc for video message as it doesn't matter for this test + let videoContent = { msgtype: "m.video", body: "some_file.mp4", url: mxc }; + let videoMessage = await client.sendMessage(testRoom, videoContent); + + await delay(3000); + let processedVideo = await client.getEvent(testRoom, videoMessage); + assert.equal(processedVideo?.redacted_because?.redacts, videoMessage, "This event should have been redacted"); + }); + + it("Doesn't redact massages that are not video.", async function () { + this.timeout(20000); + + await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir rooms add ${testRoom}`, + }); + await getFirstReaction(client, this.mjolnir.managementRoomId, "✅", async () => { + return await client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir enable MessageIsVideoProtection`, + }); + }); + + let content = { msgtype: "m.text", body: "don't redact me bro" }; + let textMessage = await client.sendMessage(testRoom, content); + + await delay(500); + let processedMessage = await client.getEvent(testRoom, textMessage); + assert.equal(Object.keys(processedMessage.content).length, 2, "This event should not have been redacted."); + }); +}); diff --git a/test/integration/commands/lockUnlockTest.ts b/test/integration/commands/lockUnlockTest.ts new file mode 100644 index 00000000..e3490ecf --- /dev/null +++ b/test/integration/commands/lockUnlockTest.ts @@ -0,0 +1,87 @@ +/* +Copyright 2025 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, RoomCreateOptions } from "@vector-im/matrix-bot-sdk"; +import { read as configRead } from "../../../src/config"; +import { newTestUser } from "../clientHelper"; +import { strict as assert } from "assert"; +import { getFirstReaction } from "./commandUtils"; + +describe("Test: lock/unlock command", function () { + let admin: MatrixClient; + let badUser: MatrixClient; + let badUserId: string; + const config = configRead(); + this.beforeEach(async () => { + admin = await newTestUser(config.homeserverUrl, { name: { contains: "lock-command-admin" } }); + await admin.start(); + badUser = await newTestUser(config.homeserverUrl, { name: { contains: "bad-user" } }); + await badUser.start(); + badUserId = await badUser.getUserId(); + }); + this.afterEach(async function () { + admin.stop(); + badUser.stop(); + }); + + it("Mjolnir asks synapse to lock and unlock a user", async function () { + this.timeout(20000); + await admin.joinRoom(this.mjolnir.managementRoomId); + const roomOption: RoomCreateOptions = { preset: "public_chat" }; + const room = await admin.createRoom(roomOption); + await badUser.joinRoom(room); + await admin.joinRoom(room); + const badUserID = await badUser.getUserId(); + + await getFirstReaction(admin, this.mjolnir.managementRoomId, "✅", async () => { + return await admin.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir lock ${badUserID}`, + }); + }); + + // locked user can't send message + try { + await badUser.sendMessage(room, { msgtype: "m.text", body: `testing` }); + assert.fail("Bad user successfully sent message."); + } catch (error) { + assert.match(error.message, /M_USER_LOCKED/i); + } + + await getFirstReaction(admin, this.mjolnir.managementRoomId, "✅", async () => { + return await admin.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.text", + body: `!mjolnir unlock ${badUserID}`, + }); + }); + + let msg = new Promise(async (resolve, reject) => { + await badUser.sendMessage(room, { msgtype: "m.text", body: `testing` }); + admin.on("room.event", (roomId, event) => { + if ( + roomId === room && + event?.type === "m.room.message" && + event.sender === badUserId && + event.content?.body === "testing" + ) { + resolve(event); + } + }); + }); + // unlocked user successfully sent message + await msg; + }); +}); diff --git a/test/integration/commands/redactCommandTest.ts b/test/integration/commands/redactCommandTest.ts index aa2da561..2eade017 100644 --- a/test/integration/commands/redactCommandTest.ts +++ b/test/integration/commands/redactCommandTest.ts @@ -67,6 +67,10 @@ describe("Test: The redaction command - if admin", function () { moderator.stop(); } + function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + await delay(700); await getMessagesByUserIn(moderator, badUserId, targetRoom, 1000, function (events) { events.map((e) => { if (e.type === "m.room.member") { @@ -75,7 +79,7 @@ describe("Test: The redaction command - if admin", function () { 1, "Only membership should be left on the membership even when it has been redacted.", ); - } else if (Object.keys(e.content).length !== 0) { + } else if (Object.keys(e.content).length !== 0 && e.type != "m.room.redaction") { throw new Error(`This event should have been redacted: ${JSON.stringify(e, null, 2)}`); } }); diff --git a/test/integration/commands/suspendCommandTest.ts b/test/integration/commands/suspendCommandTest.ts index bc882304..4ac61091 100644 --- a/test/integration/commands/suspendCommandTest.ts +++ b/test/integration/commands/suspendCommandTest.ts @@ -65,7 +65,7 @@ describe("Test: suspend/unsuspend command", function () { await badUser.sendMessage(room, { msgtype: "m.text", body: `testing` }); assert.fail("Bad user successfully sent message."); } catch (error) { - assert.match(error.message, /ORG.MATRIX.MSC3823.USER_ACCOUNT_SUSPENDED/i); + assert.match(error.message, /M_USER_SUSPENDED/i); } let reply2 = new Promise(async (resolve, reject) => { diff --git a/test/integration/dontBanSelfTest.ts b/test/integration/dontBanSelfTest.ts index d7d5d750..79a45041 100644 --- a/test/integration/dontBanSelfTest.ts +++ b/test/integration/dontBanSelfTest.ts @@ -45,7 +45,7 @@ describe("Test: Bot doesn't ban moderation room members or ignored entities.", f return new Promise((resolve) => setTimeout(resolve, ms)); } - await delay(4000); + await delay(5000); const currentMods = this.mjolnir.moderators.listAll(); let expectedCurrentMods = [await client.getUserId(), await this.mjolnir.client.getUserId()]; expectedCurrentMods.forEach((mod) => { @@ -145,4 +145,37 @@ describe("Test: Bot doesn't ban moderation room members or ignored entities.", f assert.fail("Bot has banned a member of ignore list."); } }); + + it("Does not include historical members in mod cache", async function () { + this.timeout(20000); + + function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + const leaverClient = await newTestUser(this.config.homeserverUrl, { + name: { contains: "mod-room-test-leaver" }, + }); + await leaverClient.joinRoom(this.config.managementRoom); + + await delay(5000); + const currentMods = this.mjolnir.moderators.listAll(); + let expectedCurrentMods = [ + await client.getUserId(), + await this.mjolnir.client.getUserId(), + await leaverClient.getUserId(), + ]; + expectedCurrentMods.forEach((mod) => { + if (!currentMods.includes(mod)) { + assert.fail("Expected mod not found."); + } + }); + const roomID = await leaverClient.resolveRoom(this.config.managementRoom); + await leaverClient.leaveRoom(roomID); + await delay(1000); + let updatedMods = this.mjolnir.moderators.listAll(); + if (updatedMods.includes(await leaverClient.getUserId())) { + assert.fail("Leaver should not be in moderator list."); + } + }); }); diff --git a/test/integration/mentionSpamProtectionTest.ts b/test/integration/mentionSpamProtectionTest.ts index bda36ae0..9061e067 100644 --- a/test/integration/mentionSpamProtectionTest.ts +++ b/test/integration/mentionSpamProtectionTest.ts @@ -95,12 +95,6 @@ describe("Test: Mention spam protection", function () { }); // Also covers HTML mentions const mentionUsers = Array.from({ length: DEFAULT_MAX_MENTIONS + 1 }, (_, i) => `@user${i}:example.org`); - const mentionDisplaynames = Array.from({ length: DEFAULT_MAX_MENTIONS + 1 }, (_, i) => `Test User ${i}`); - - // Pre-set the displayname cache. - let protection = this.mjolnir.protectionManager.protections.get("MentionSpam"); - protection.roomDisplaynameCache.set(room, mentionDisplaynames); - const messageWithTextMentions = await client.sendText(room, mentionUsers.join(" ")); const messageWithHTMLMentions = await client.sendHtmlText( room, @@ -113,7 +107,6 @@ describe("Test: Mention spam protection", function () { user_ids: mentionUsers, }, }); - const messageWithDisplaynameMentions = await client.sendText(room, mentionDisplaynames.join(" ")); await delay(500); @@ -125,24 +118,5 @@ describe("Test: Mention spam protection", function () { const fetchedMentionsEvent = await client.getEvent(room, messageWithMMentions); assert.equal(Object.keys(fetchedMentionsEvent.content).length, 0, "This event should have been redacted"); - - const fetchedDisplaynameEvent = await client.getEvent(room, messageWithDisplaynameMentions); - assert.equal(Object.keys(fetchedDisplaynameEvent.content).length, 0, "This event should have been redacted"); - - // send messages after activating protection, they should be auto-redacted - const messages = []; - for (let i = 0; i < 10; i++) { - let nextMessage = await client.sendText(room, `hello${i}`); - messages.push(nextMessage); - } - - messages.forEach(async (eventID) => { - await client.getEvent(room, eventID); - assert.equal( - Object.keys(fetchedDisplaynameEvent.content).length, - 0, - "This event should have been redacted", - ); - }); }); }); diff --git a/test/integration/mjolnirSetupUtils.ts b/test/integration/mjolnirSetupUtils.ts index 97f4c2b4..01267cad 100644 --- a/test/integration/mjolnirSetupUtils.ts +++ b/test/integration/mjolnirSetupUtils.ts @@ -82,6 +82,7 @@ export async function makeMjolnir(config: IConfig): Promise { LogService.setLogger(new RichConsoleLogger()); LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG)); LogService.info("test/mjolnirSetupUtils", "Starting bot..."); + LogService.muteModule("MatrixClientLite"); let client = new MatrixClient(config.homeserverUrl, accessToken, new MemoryStorageProvider(), cryptoStore); await client.crypto.prepare(); diff --git a/yarn.lock b/yarn.lock index 29113bca..6cef2418 100644 --- a/yarn.lock +++ b/yarn.lock @@ -69,6 +69,44 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@hapi/boom@^10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-10.0.1.tgz#ebb14688275ae150aa6af788dbe482e6a6062685" + integrity sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA== + dependencies: + "@hapi/hoek" "^11.0.2" + +"@hapi/bourne@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-3.0.0.tgz#f11fdf7dda62fe8e336fa7c6642d9041f30356d7" + integrity sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w== + +"@hapi/hoek@^11.0.2", "@hapi/hoek@^11.0.4": + version "11.0.7" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-11.0.7.tgz#56a920793e0a42d10e530da9a64cc0d3919c4002" + integrity sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ== + +"@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" + integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ== + +"@hapi/topo@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012" + integrity sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@hapi/wreck@^18.0.0": + version "18.1.0" + resolved "https://registry.yarnpkg.com/@hapi/wreck/-/wreck-18.1.0.tgz#68e631fc7568ebefc6252d5b86cb804466c8dbe6" + integrity sha512-0z6ZRCmFEfV/MQqkQomJ7sl/hyxvcZM7LtuVqN3vdAO4vM9eBbowl0kaqQj9EJJQab+3Uuh1GxbGIBFy4NfJ4w== + dependencies: + "@hapi/boom" "^10.0.1" + "@hapi/bourne" "^3.0.0" + "@hapi/hoek" "^11.0.2" + "@humanwhocodes/config-array@^0.5.0": version "0.5.0" resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz" @@ -183,6 +221,23 @@ "@sentry/types" "7.22.0" tslib "^1.9.3" +"@sideway/address@^4.1.5": + version "4.1.5" + resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5" + integrity sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/formula@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f" + integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg== + +"@sideway/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" + integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== + "@tensorflow/tfjs-backend-cpu@4.21.0": version "4.21.0" resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-4.21.0.tgz#27a489f838b88aa98220da0aaf29f58d16fc9d05" @@ -505,6 +560,11 @@ resolved "https://registry.npmjs.org/@types/shell-quote/-/shell-quote-1.7.1.tgz" integrity sha512-SWZ2Nom1pkyXCDohRSrkSKvDh8QOG9RfAsrt5/NsPQC4UQJ55eG0qClA40I+Gkez4KTQ0uDUT8ELRXThf3J5jw== +"@types/simple-oauth2@^5.0.7": + version "5.0.7" + resolved "https://registry.yarnpkg.com/@types/simple-oauth2/-/simple-oauth2-5.0.7.tgz#92df8c2fba8ec58c9fea6dd3206d4395de19e721" + integrity sha512-8JbWVJbiTSBQP/7eiyGKyXWAqp3dKQZpaA+pdW16FCi32ujkzRMG8JfjoAzdWt6W8U591ZNdHcPtP2D7ILTKuA== + "@types/stack-utils@^2.0.0": version "2.0.1" resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz" @@ -775,10 +835,10 @@ aws4@^1.8.0: resolved "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz" integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== -axios@^1.7.6: - version "1.7.7" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.7.tgz#2f554296f9892a72ac8d8e4c5b79c14a91d0a47f" - integrity sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q== +axios@^1.8.2: + version "1.8.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.2.tgz#fabe06e241dfe83071d4edfbcaa7b1c3a40f7979" + integrity sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg== dependencies: follow-redirects "^1.15.6" form-data "^4.0.0" @@ -1086,9 +1146,9 @@ core-util-is@1.0.2: integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== cross-spawn@^7.0.2: - version "7.0.3" - resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" @@ -1153,6 +1213,13 @@ debug@^3.1.0: dependencies: ms "^2.1.1" +debug@^4.3.4: + version "4.4.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a" + integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA== + dependencies: + ms "^2.1.3" + decamelize@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz" @@ -2353,6 +2420,17 @@ jest-regex-util@^27.0.6: resolved "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.0.6.tgz" integrity sha512-SUhPzBsGa1IKm8hx2F4NfTGGp+r7BXJ4CulsZ1k2kI+mGLG+lxGrs76veN2LF/aUdGosJBzKgXmNCw+BzFqBDQ== +joi@^17.6.4: + version "17.13.3" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.3.tgz#0f5cc1169c999b30d344366d384b12d92558bcec" + integrity sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA== + dependencies: + "@hapi/hoek" "^9.3.0" + "@hapi/topo" "^5.1.0" + "@sideway/address" "^4.1.5" + "@sideway/formula" "^3.0.1" + "@sideway/pinpoint" "^2.0.0" + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" @@ -2578,11 +2656,6 @@ lru-cache@^10.0.1: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== -lru-cache@^11.0.1: - version "11.0.1" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.0.1.tgz#3a732fbfedb82c5ba7bca6564ad3f42afcb6e147" - integrity sha512-CgeuL5uom6j/ZVrg7G/+1IXqRY8JXX4Hghfy5YE0EhoYQWvndP1kufu58cmZLNIDKnRhZrXfdS9urVWx98AipQ== - lru-cache@^4.1.5: version "4.1.5" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz" @@ -2811,7 +2884,7 @@ ms@2.1.2: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3, ms@^2.1.1: +ms@2.1.3, ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -3025,10 +3098,10 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" -parse-duration@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/parse-duration/-/parse-duration-1.0.2.tgz" - integrity sha512-Dg27N6mfok+ow1a2rj/nRjtCfaKrHUZV2SJpEn/s8GaVUSlf4GGRCRP1c13Hj+wfPKVMrFDqLMLITkYKgKxyyg== +parse-duration@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/parse-duration/-/parse-duration-2.1.3.tgz#79a61a3ae224a5f4d1b71a8fa79e53d5aa90c902" + integrity sha512-MtbharL7Bets65qDBXuDOHHWyY1BxTJZmJ/xGmS90iEbKE0gZ6yZpZtCda7O79GeOi/f0NwBaplIuReExIoVsw== parse-srcset@^1.0.2: version "1.0.2" @@ -3668,6 +3741,16 @@ signal-exit@^3.0.0: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== +simple-oauth2@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/simple-oauth2/-/simple-oauth2-5.1.0.tgz#1398fe2b8f4b4066298d63c155501b31b42238f2" + integrity sha512-gWDa38Ccm4MwlG5U7AlcJxPv3lvr80dU7ARJWrGdgvOKyzSj1gr3GBPN1rABTedAYvC/LsGYoFuFxwDBPtGEbw== + dependencies: + "@hapi/hoek" "^11.0.4" + "@hapi/wreck" "^18.0.0" + debug "^4.3.4" + joi "^17.6.4" + simple-swizzle@^0.2.2: version "0.2.2" resolved "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz"