diff --git a/src/ManagementRoom.ts b/src/ManagementRoom.ts index d832d6a..28bae9b 100644 --- a/src/ManagementRoom.ts +++ b/src/ManagementRoom.ts @@ -16,8 +16,6 @@ limitations under the License. import { extractRequestError, LogLevel, LogService, MatrixClient, MessageType, Permalinks, TextualMessageEventContent, UserID } from "matrix-bot-sdk"; import { IConfig } from "./config"; -import ErrorCache from "./ErrorCache"; -import { RoomUpdateError } from "./models/RoomUpdateError"; import { htmlEscape } from "./utils"; const levelToFn = { @@ -102,7 +100,7 @@ export default class ManagementRoomOutput { format: "org.matrix.custom.html", }; if (!isRecursive) { - evContent = await this.replaceRoomIdsWithPills(this, clientMessage, new Set(roomIds), "m.notice"); + evContent = await this.replaceRoomIdsWithPills(clientMessage, new Set(roomIds), "m.notice"); } await client.sendMessage(this.managementRoomId, evContent); diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 7d608fb..92a4d96 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -20,37 +20,24 @@ import { LogLevel, LogService, MatrixClient, - MatrixGlob, MembershipEvent, Permalinks, - UserID, - TextualMessageEventContent } from "matrix-bot-sdk"; -import { ALL_RULE_TYPES as ALL_BAN_LIST_RULE_TYPES, RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/ListRule"; -import { applyServerAcls } from "./actions/ApplyAcl"; -import { RoomUpdateError } from "./models/RoomUpdateError"; +import { ALL_RULE_TYPES as ALL_BAN_LIST_RULE_TYPES } from "./models/ListRule"; import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler"; -import { applyUserBans } from "./actions/ApplyBan"; -import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache"; -import { Protection } from "./protections/IProtection"; -import { PROTECTIONS } from "./protections/protections"; -import { ConsequenceType, Consequence } from "./protections/consequence"; -import { ProtectionSettingValidationError } from "./protections/ProtectionSettings"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; -import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue"; import { htmlEscape } from "./utils"; import { ReportManager } from "./report/ReportManager"; import { ReportPoller } from "./report/ReportPoller"; import { WebAPIs } from "./webapis/WebAPIs"; import RuleServer from "./models/RuleServer"; -import { RoomMemberManager } from "./RoomMembers"; -import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker"; import { ThrottlingQueue } from "./queues/ThrottlingQueue"; import { IConfig } from "./config"; -import PolicyList, { ListRuleChange } from "./models/PolicyList"; +import PolicyList from "./models/PolicyList"; import { ProtectedRooms } from "./ProtectedRooms"; import ManagementRoomOutput from "./ManagementRoom"; +import { ProtectionManager } from "./protections/protections"; export const STATE_NOT_STARTED = "not_started"; export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; @@ -58,10 +45,8 @@ export const STATE_SYNCING = "syncing"; export const STATE_RUNNING = "running"; const WATCHED_LISTS_EVENT_TYPE = "org.matrix.mjolnir.watched_lists"; -const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections"; -const PROTECTED_ROOMS_EVENT_TYPE = "org.matrix.mjolnir.protected_rooms"; const WARN_UNPROTECTED_ROOM_EVENT_PREFIX = "org.matrix.mjolnir.unprotected_room_warning.for."; -const CONSEQUENCE_EVENT_DATA = "org.matrix.mjolnir.consequence"; + /** * Synapse will tell us where we last got to on polling reports, so we need * to store that for pagination on further polls @@ -72,8 +57,6 @@ export class Mjolnir { private displayName: string; private localpart: string; private currentState: string = STATE_NOT_STARTED; - public readonly roomJoins: RoomMemberManager; - public protections = new Map(); /** * This is for users who are not listed on a watchlist, * but have been flagged by the automatic spam detection as suispicous @@ -83,19 +66,18 @@ export class Mjolnir { * Every room that we are joined to except the management room. Used to implement `config.protectAllJoinedRooms`. */ private protectedJoinedRoomIds: string[] = []; - private protectedRoomsTracker: ProtectedRooms; - /** - * These are rooms that were explicitly said to be protected either in the config, or by what is present in the account data for `org.matrix.mjolnir.protected_rooms`. - */ - private explicitlyProtectedRoomIds: string[] = []; - private unprotectedWatchedListRooms: string[] = []; + public readonly protectedRoomsTracker: ProtectedRooms; // FIXME: Construct private webapis: WebAPIs; public taskQueue: ThrottlingQueue; - private managementRoom: ManagementRoomOutput; + public readonly managementRoom: ManagementRoomOutput; /* * Config-enabled polling of reports in Synapse, so Mjolnir can react to reports */ private reportPoller?: ReportPoller; + + public readonly protectionManager: ProtectionManager; + public readonly reportManager: ReportManager; + /** * Adds a listener to the client that will automatically accept invitations. * @param {MatrixClient} client @@ -131,7 +113,7 @@ export class Mjolnir { const spaceUserIds = await client.getJoinedRoomMembers(spaceId) .catch(async e => { if (e.body?.errcode === "M_FORBIDDEN") { - await mjolnir.logMessage(LogLevel.ERROR, 'Mjolnir', `Mjolnir is not in the space configured for acceptInvitesFromSpace, did you invite it?`); + await mjolnir.managementRoom.logMessage(LogLevel.ERROR, 'Mjolnir', `Mjolnir is not in the space configured for acceptInvitesFromSpace, did you invite it?`); await client.joinRoom(spaceId); return await client.getJoinedRoomMembers(spaceId); } else { @@ -177,7 +159,7 @@ export class Mjolnir { const ruleServer = config.web.ruleServer ? new RuleServer() : null; const mjolnir = new Mjolnir(client, managementRoomId, config, protectedRooms, policyLists, ruleServer); - await mjolnir.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status."); + await mjolnir.managementRoom.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status."); Mjolnir.addJoinOnInviteListener(mjolnir, client, config); return mjolnir; } @@ -190,17 +172,11 @@ export class Mjolnir { * All the rooms that Mjolnir is protecting and their permalinks. * If `config.protectAllJoinedRooms` is specified, then `protectedRooms` will be all joined rooms except watched banlists that we can't protect (because they aren't curated by us). */ - public readonly protectedRooms: { [roomId: string]: string }, + protectedRooms: { [roomId: string]: string }, private policyLists: PolicyList[], // Combines the rules from ban lists so they can be served to a homeserver module or another consumer. public readonly ruleServer: RuleServer | null, ) { - this.explicitlyProtectedRoomIds = Object.keys(this.protectedRooms); - - for (const reason of this.config.automaticallyRedactForReasons) { - this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase())); - } - // Setup bot. client.on("room.event", this.handleEvent.bind(this)); @@ -260,15 +236,14 @@ export class Mjolnir { // Setup Web APIs console.log("Creating Web APIs"); - const reportManager = new ReportManager(this); - reportManager.on("report.new", this.handleReport.bind(this)); - this.webapis = new WebAPIs(reportManager, this.config, this.ruleServer); + this.reportManager = new ReportManager(this); + this.webapis = new WebAPIs(this.reportManager, this.config, this.ruleServer); if (config.pollReports) { - this.reportPoller = new ReportPoller(this, reportManager); + this.reportPoller = new ReportPoller(this, this.reportManager); } - // Setup join/leave listener - this.roomJoins = new RoomMemberManager(this.client); this.taskQueue = new ThrottlingQueue(this, config.backgroundDelayMS); + + this.protectionManager = new ProtectionManager(this); } public get lists(): PolicyList[] { @@ -279,10 +254,6 @@ export class Mjolnir { return this.currentState; } - public get enabledProtections(): Protection[] { - return [...this.protections.values()].filter(p => p.enabled); - } - /** * Returns the handler to flag a user for redaction, removing any future messages that they send. * Typically this is used by the flooding or image protection on users that have not been banned from a list yet. @@ -323,31 +294,19 @@ export class Mjolnir { await this.managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir@startup", "Loading protected rooms..."); await this.resyncJoinedRooms(false); - try { - const data: { rooms?: string[] } | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE); - if (data && data['rooms']) { - for (const roomId of data['rooms']) { - this.protectedRooms[roomId] = Permalinks.forRoom(roomId); - this.explicitlyProtectedRoomIds.push(roomId); - this.protectedRoomActivityTracker.addProtectedRoom(roomId); - } - } - } catch (e) { - LogService.warn("Mjolnir", extractRequestError(e)); - } + await this.protectedRoomsTracker.start(); await this.buildWatchedPolicyLists(); - this.applyUnprotectedRooms(); if (this.config.verifyPermissionsOnStartup) { await this.managementRoom.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions..."); - await this.verifyPermissions(this.config.verboseLogging); + await this.protectedRoomsTracker.verifyPermissions(this.config.verboseLogging); } this.currentState = STATE_SYNCING; if (this.config.syncOnStartup) { await this.managementRoom.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists..."); - await this.syncLists(this.config.verboseLogging); - await this.registerProtections(); + await this.protectedRoomsTracker.syncLists(this.config.verboseLogging); + await this.protectionManager.start(); } this.currentState = STATE_RUNNING; @@ -376,42 +335,6 @@ export class Mjolnir { this.reportPoller?.stop(); } - public async addProtectedRoom(roomId: string) { - this.protectedRooms[roomId] = Permalinks.forRoom(roomId); - this.roomJoins.addRoom(roomId); - - const unprotectedIdx = this.unprotectedWatchedListRooms.indexOf(roomId); - if (unprotectedIdx >= 0) this.unprotectedWatchedListRooms.splice(unprotectedIdx, 1); - this.explicitlyProtectedRoomIds.push(roomId); - - let additionalProtectedRooms: { rooms?: string[] } | null = null; - try { - additionalProtectedRooms = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE); - } catch (e) { - LogService.warn("Mjolnir", extractRequestError(e)); - } - const rooms = (additionalProtectedRooms?.rooms ?? []); - rooms.push(roomId); - await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: rooms }); - } - - public async removeProtectedRoom(roomId: string) { - delete this.protectedRooms[roomId]; - this.roomJoins.removeRoom(roomId); - - const idx = this.explicitlyProtectedRoomIds.indexOf(roomId); - if (idx >= 0) this.explicitlyProtectedRoomIds.splice(idx, 1); - - let additionalProtectedRooms: { rooms?: string[] } | null = null; - try { - additionalProtectedRooms = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE); - } catch (e) { - LogService.warn("Mjolnir", extractRequestError(e)); - } - additionalProtectedRooms = { rooms: additionalProtectedRooms?.rooms?.filter(r => r !== roomId) ?? [] }; - await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms); - } - // need to brewritten to add/remove from a ProtectedRooms instance. private async resyncJoinedRooms(withSync = true) { // this is really terrible! @@ -420,192 +343,29 @@ export class Mjolnir { if (!this.config.protectAllJoinedRooms) return; const joinedRoomIds = (await this.client.getJoinedRooms()) - .filter(r => r !== this.managementRoomId && !this.unprotectedWatchedListRooms.includes(r)); + .filter(r => r !== this.managementRoomId && !this.protectedRoomsTracker.unprotectedWatchedListRooms.includes(r)); const oldRoomIdsSet = new Set(this.protectedJoinedRoomIds); const joinedRoomIdsSet = new Set(joinedRoomIds); // find every room that we have left (since last time) for (const roomId of oldRoomIdsSet.keys()) { if (!joinedRoomIdsSet.has(roomId)) { // Then we have left this room. - delete this.protectedRooms[roomId]; this.protectedRoomsTracker.removeProtectedRoom(roomId); - this.roomJoins.removeRoom(roomId); } } // find every room that we have joined (since last time). for (const roomId of joinedRoomIdsSet.keys()) { if (!oldRoomIdsSet.has(roomId)) { // Then we have joined this room - this.roomJoins.addRoom(roomId); - this.protectedRooms[roomId] = Permalinks.forRoom(roomId); await this.protectedRoomsTracker.addProtectedRoom(roomId); } } - this.applyUnprotectedRooms(); - if (withSync) { - await this.syncLists(this.config.verboseLogging); + await this.protectedRoomsTracker.syncLists(this.config.verboseLogging); } } - /* - * Take all the builtin protections, register them to set their enabled (or not) state and - * update their settings with any saved non-default values - */ - private async registerProtections() { - for (const protection of PROTECTIONS) { - try { - await this.registerProtection(protection); - } catch (e) { - LogService.warn("Mjolnir", extractRequestError(e)); - } - } - } - - /* - * Make a list of the names of enabled protections and save them in a state event - */ - private async saveEnabledProtections() { - const protections = this.enabledProtections.map(p => p.name); - await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: protections }); - } - /* - * Enable a protection by name and persist its enable state in to a state event - * - * @param name The name of the protection whose settings we're enabling - */ - public async enableProtection(name: string) { - const protection = this.protections.get(name); - if (protection !== undefined) { - protection.enabled = true; - await this.saveEnabledProtections(); - } - } - /* - * Disable a protection by name and remove it from the persistent list of enabled protections - * - * @param name The name of the protection whose settings we're disabling - */ - public async disableProtection(name: string) { - const protection = this.protections.get(name); - if (protection !== undefined) { - protection.enabled = false; - await this.saveEnabledProtections(); - } - } - - /* - * Read org.matrix.mjolnir.setting state event, find any saved settings for - * the requested protectionName, then iterate and validate against their parser - * counterparts in Protection.settings and return those which validate - * - * @param protectionName The name of the protection whose settings we're reading - * @returns Every saved setting for this protectionName that has a valid value - */ - public async getProtectionSettings(protectionName: string): Promise<{ [setting: string]: any }> { - let savedSettings: { [setting: string]: any } = {} - try { - savedSettings = await this.client.getRoomStateEvent( - this.managementRoomId, 'org.matrix.mjolnir.setting', protectionName - ); - } catch { - // setting does not exist, return empty object - return {}; - } - - const settingDefinitions = this.protections.get(protectionName)?.settings ?? {}; - const validatedSettings: { [setting: string]: any } = {} - for (let [key, value] of Object.entries(savedSettings)) { - if ( - // is this a setting name with a known parser? - key in settingDefinitions - // is the datatype of this setting's value what we expect? - && typeof (settingDefinitions[key].value) === typeof (value) - // is this setting's value valid for the setting? - && settingDefinitions[key].validate(value) - ) { - validatedSettings[key] = value; - } else { - await this.managementRoom.logMessage( - LogLevel.WARN, - "getProtectionSetting", - `Tried to read ${protectionName}.${key} and got invalid value ${value}` - ); - } - } - return validatedSettings; - } - - /* - * Takes an object of settings we want to change and what their values should be, - * check that their values are valid, combine them with current saved settings, - * then save the amalgamation to a state event - * - * @param protectionName Which protection these settings belong to - * @param changedSettings The settings to change and their values - */ - public async setProtectionSettings(protectionName: string, changedSettings: { [setting: string]: any }): Promise { - const protection = this.protections.get(protectionName); - if (protection === undefined) { - return; - } - - const validatedSettings: { [setting: string]: any } = await this.getProtectionSettings(protectionName); - - for (let [key, value] of Object.entries(changedSettings)) { - if (!(key in protection.settings)) { - throw new ProtectionSettingValidationError(`Failed to find protection setting by name: ${key}`); - } - if (typeof (protection.settings[key].value) !== typeof (value)) { - throw new ProtectionSettingValidationError(`Invalid type for protection setting: ${key} (${typeof (value)})`); - } - if (!protection.settings[key].validate(value)) { - throw new ProtectionSettingValidationError(`Invalid value for protection setting: ${key} (${value})`); - } - validatedSettings[key] = value; - } - - await this.client.sendStateEvent( - this.managementRoomId, 'org.matrix.mjolnir.setting', protectionName, validatedSettings - ); - } - - /* - * Given a protection object; add it to our list of protections, set whether it is enabled - * and update its settings with any saved non-default values. - * - * @param protection The protection object we want to register - */ - public async registerProtection(protection: Protection) { - this.protections.set(protection.name, protection) - - let enabledProtections: { enabled: string[] } | null = null; - try { - enabledProtections = await this.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE); - } catch { - // this setting either doesn't exist, or we failed to read it (bad network?) - // TODO: retry on certain failures? - } - protection.enabled = enabledProtections?.enabled.includes(protection.name) ?? false; - - const savedSettings = await this.getProtectionSettings(protection.name); - for (let [key, value] of Object.entries(savedSettings)) { - // this.getProtectionSettings() validates this data for us, so we don't need to - protection.settings[key].setValue(value); - } - } - /* - * Given a protection object; remove it from our list of protections. - * - * @param protection The protection object we want to unregister - */ - public unregisterProtection(protectionName: string) { - if (!(protectionName in this.protections)) { - throw new Error("Failed to find protection by name: " + protectionName); - } - this.protections.delete(protectionName); - } /** * Helper for constructing `PolicyList`s and making sure they have the right listeners set up. @@ -615,22 +375,12 @@ export class Mjolnir { private async addPolicyList(roomId: string, roomRef: string): Promise { const list = new PolicyList(roomId, roomRef, this.client); this.ruleServer?.watch(list); - list.on('PolicyList.batch', this.syncWithPolicyList.bind(this)); + list.on('PolicyList.batch', (...args) => this.protectedRoomsTracker.syncWithPolicyList(...args)); await list.updateList(); this.policyLists.push(list); return list; } - /** - * Get a protection by name. - * - * @return If there is a protection with this name *and* it is enabled, - * return the protection. - */ - public getProtection(protectionName: string): Protection | null { - return this.protections.get(protectionName) ?? null; - } - public async watchList(roomRef: string): Promise { const joinedRooms = await this.client.getJoinedRooms(); const permalink = Permalinks.parseUrl(roomRef); @@ -673,13 +423,12 @@ export class Mjolnir { public async warnAboutUnprotectedPolicyListRoom(roomId: string) { if (!this.config.protectAllJoinedRooms) return; // doesn't matter - if (this.explicitlyProtectedRoomIds.includes(roomId)) return; // explicitly protected + if (this.protectedRoomsTracker.explicitlyProtectedRoomIds.includes(roomId)) return; // explicitly protected const createEvent = new CreateEvent(await this.client.getRoomStateEvent(roomId, "m.room.create", "")); if (createEvent.creator === await this.client.getUserId()) return; // we created it - if (!this.unprotectedWatchedListRooms.includes(roomId)) this.unprotectedWatchedListRooms.push(roomId); - this.applyUnprotectedRooms(); + this.protectedRoomsTracker.addUnprotectedWatchedListRoom(roomId); try { const accountData: { warned: boolean } | null = await this.client.getAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId); @@ -692,13 +441,6 @@ export class Mjolnir { await this.client.setAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId, { warned: true }); } - private applyUnprotectedRooms() { - for (const roomId of this.unprotectedWatchedListRooms) { - delete this.protectedRooms[roomId]; - this.protectedRoomActivityTracker.removeProtectedRoom(roomId); - } - } - private async buildWatchedPolicyLists() { this.policyLists = []; const joinedRooms = await this.client.getJoinedRooms(); @@ -724,44 +466,6 @@ export class Mjolnir { } } - - - private requiredProtectionPermissions(): Set { - return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat()) - } - - private async handleConsequence(protection: Protection, roomId: string, eventId: string, sender: string, consequence: Consequence) { - switch (consequence.type) { - case ConsequenceType.alert: - break; - case ConsequenceType.redact: - await this.client.redactEvent(roomId, eventId, "abuse detected"); - break; - case ConsequenceType.ban: - await this.client.banUser(sender, roomId, "abuse detected"); - break; - } - - let message = `protection ${protection.name} enacting ${ConsequenceType[consequence.type]}` - + ` against ${htmlEscape(sender)}` - + ` in ${htmlEscape(roomId)}`; - if (consequence.reason !== undefined) { - // even though internally-sourced, there's no promise that `consequence.reason` - // will never have user-supplied information, so escape it - message += ` (reason: ${htmlEscape(consequence.reason)})`; - } - - await this.client.sendMessage(this.managementRoomId, { - msgtype: "m.notice", - body: message, - [CONSEQUENCE_EVENT_DATA]: { - who: sender, - room: roomId, - type: ConsequenceType[consequence.type] - } - }); - } - private async handleEvent(roomId: string, event: any) { // Check for UISI errors if (roomId === this.managementRoomId) { @@ -783,35 +487,6 @@ export class Mjolnir { policyList.updateForEvent(event) } } - - if (roomId in this.protectedRooms) { - if (event['sender'] === await this.client.getUserId()) return; // Ignore ourselves - - // Iterate all the enabled protections - for (const protection of this.enabledProtections) { - let consequence: Consequence | undefined = undefined; - try { - consequence = await protection.handleEvent(this, roomId, event); - } catch (e) { - const eventPermalink = Permalinks.forEvent(roomId, event['event_id']); - LogService.error("Mjolnir", "Error handling protection: " + protection.name); - LogService.error("Mjolnir", "Failed event: " + eventPermalink); - LogService.error("Mjolnir", extractRequestError(e)); - await this.client.sendNotice(this.managementRoomId, "There was an error processing an event through a protection - see log for details. Event: " + eventPermalink); - continue; - } - - if (consequence !== undefined) { - await this.handleConsequence(protection, roomId, event["event_id"], event["sender"], consequence); - } - } - - // Run the event handlers - we always run this after protections so that the protections - // can flag the event for redaction. - await this.unlistedUserRedactionHandler.handleEvent(roomId, event, this); - - - } } public async isSynapseAdmin(): Promise { @@ -856,10 +531,4 @@ export class Mjolnir { return extractRequestError(e); } } - - private async handleReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) { - for (const protection of this.enabledProtections) { - await protection.handleReport(this, roomId, reporterId, event, reason); - } - } } diff --git a/src/ProtectedRooms.ts b/src/ProtectedRooms.ts index 20d25f0..38c904d 100644 --- a/src/ProtectedRooms.ts +++ b/src/ProtectedRooms.ts @@ -22,10 +22,14 @@ import { RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/ListRule"; import PolicyList, { ListRuleChange } from "./models/PolicyList"; import { RoomUpdateError } from "./models/RoomUpdateError"; import { ServerAcl } from "./models/ServerAcl"; +import { ProtectionManager } from "./protections/protections"; import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue"; import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker"; +import { RoomMemberManager } from "./RoomMembers"; import { htmlEscape } from "./utils"; +const PROTECTED_ROOMS_EVENT_TYPE = "org.matrix.mjolnir.protected_rooms"; + /** * When you consider spaces https://github.com/matrix-org/mjolnir/issues/283 * rather than indexing rooms via some collection, you instead have rooms @@ -37,9 +41,6 @@ import { htmlEscape } from "./utils"; * as in future we might want to borrow this class to represent a space. */ export class ProtectedRooms { - - private protectedRooms = new Set(); - private policyLists: PolicyList[]; private protectedRoomActivityTracker: ProtectedRoomActivityTracker; @@ -54,6 +55,31 @@ export class ProtectedRooms { private automaticRedactionReasons: MatrixGlob[] = []; + /** + * A list of rooms that we watch and protect. + */ + private readonly _protectedRooms = new Map(); + public get protectedRooms(): Iterable { + return this._protectedRooms.keys(); + } + + /** + * A list of rooms that we watch but do not protect. + */ + private _unprotectedWatchedListRooms: string[] = []; + public get unprotectedWatchedListRooms(): ReadonlyArray { + return this._unprotectedWatchedListRooms; + } + + /** + * These are rooms that were explicitly said to be protected either in the config, or by what is present in the account data for `org.matrix.mjolnir.protected_rooms`. + */ + private _explicitlyProtectedRoomIds: string[] = []; + public get explicitlyProtectedRoomIds(): ReadonlyArray { + return this._explicitlyProtectedRoomIds; + } + public readonly roomJoins: RoomMemberManager; + /** * Used to provide mutual exclusion when synchronizing rooms with the state of a policy list. * This is because requests operating with rules from an older version of the list that are slow @@ -69,10 +95,37 @@ export class ProtectedRooms { private readonly clientUserId: string, private readonly managementRoomId: string, private readonly managementRoom: ManagementRoomOutput, + private readonly protections: ProtectionManager, private readonly config: IConfig, + protectedRooms: string[], ) { + this._explicitlyProtectedRoomIds = protectedRooms; + + for (const reason of this.config.automaticallyRedactForReasons) { + this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase())); + } + // Setup room activity watcher this.protectedRoomActivityTracker = new ProtectedRoomActivityTracker(client); + + // Setup join/leave listener + this.roomJoins = new RoomMemberManager(this.client); + } + + public async start() { + try { + const data: { rooms?: string[] } | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE); + if (data && data['rooms']) { + for (const roomId of data['rooms']) { + this.addProtectedRoom(roomId); + } + } + } catch (e) { + LogService.warn("ProtectedRooms", extractRequestError(e)); + } + for (const roomId of this._unprotectedWatchedListRooms) { + this.removeProtectedRoom(roomId); + } } public queueRedactUserMessagesIn(userId: string, roomId: string) { @@ -110,7 +163,7 @@ export class ProtectedRooms { // power levels were updated - recheck permissions this.errorCache.resetError(roomId, ERROR_KIND_PERMISSION); await this.managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir", `Power levels changed in ${roomId} - checking permissions...`, roomId); - const errors = await this.verifyPermissionsIn(roomId); + const errors = await this.protections.verifyPermissionsIn(roomId); const hadErrors = await this.printActionResult(errors); if (!hadErrors) { await this.managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir", `All permissions look OK.`); @@ -161,19 +214,53 @@ export class ProtectedRooms { } public async addProtectedRoom(roomId: string): Promise { - if (this.protectedRooms.has(roomId)) { + if (this._protectedRooms.has(roomId)) { // we need to protect ourselves form syncing all the lists unnecessarily // as Mjolnir does call this method repeatedly. return; } - this.protectedRooms.add(roomId); + this._protectedRooms.set(roomId, Permalinks.forRoom(roomId)); + this._explicitlyProtectedRoomIds.push(roomId); this.protectedRoomActivityTracker.addProtectedRoom(roomId); + this.roomJoins.addRoom(roomId); + + const unprotectedIdx = this._unprotectedWatchedListRooms.indexOf(roomId); + if (unprotectedIdx >= 0) this._unprotectedWatchedListRooms.splice(unprotectedIdx, 1); + this._explicitlyProtectedRoomIds.push(roomId); + + let additionalProtectedRooms: { rooms?: string[] } | null = null; + try { + additionalProtectedRooms = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE); + } catch (e) { + LogService.warn("Mjolnir", extractRequestError(e)); + } + const rooms = (additionalProtectedRooms?.rooms ?? []); + rooms.push(roomId); + await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: rooms }); + await this.syncLists(this.config.verboseLogging); } - public removeProtectedRoom(roomId: string): void { + public async removeProtectedRoom(roomId: string) { this.protectedRoomActivityTracker.removeProtectedRoom(roomId); - this.protectedRooms.delete(roomId); + this._protectedRooms.delete(roomId); + this.roomJoins.removeRoom(roomId); + + const idx = this._explicitlyProtectedRoomIds.indexOf(roomId); + if (idx >= 0) this._explicitlyProtectedRoomIds.splice(idx, 1); + + let additionalProtectedRooms: { rooms?: string[] } | null = null; + try { + additionalProtectedRooms = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE); + } catch (e) { + LogService.warn("Mjolnir", extractRequestError(e)); + } + additionalProtectedRooms = { rooms: additionalProtectedRooms?.rooms?.filter(r => r !== roomId) ?? [] }; + await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms); + } + + public isProtectedRoom(roomId: string): boolean { + return this._protectedRooms.has(roomId); } /** @@ -182,7 +269,7 @@ export class ProtectedRooms { * @param policyList The `PolicyList` which we will check for changes and apply them to all protected rooms. * @returns When all of the protected rooms have been updated. */ - private async syncWithPolicyList(policyList: PolicyList): Promise { + public async syncWithPolicyList(policyList: PolicyList): Promise { // this bit can move away into a listener. const changes = await policyList.updateList(); @@ -445,8 +532,8 @@ export class ProtectedRooms { public async verifyPermissions(verbose = true, printRegardless = false) { const errors: RoomUpdateError[] = []; - for (const roomId of Object.keys(this.protectedRooms)) { - errors.push(...(await this.verifyPermissionsIn(roomId))); + for (const roomId of this._protectedRooms.keys()) { + errors.push(...(await this.protections.verifyPermissionsIn(roomId))); } const hadErrors = await this.printActionResult(errors, "Permission errors in protected rooms:", printRegardless); @@ -462,90 +549,11 @@ export class ProtectedRooms { } } - private async verifyPermissionsIn(roomId: string): Promise { - const errors: RoomUpdateError[] = []; - const additionalPermissions = this.requiredProtectionPermissions(); - - try { - const ownUserId = await this.client.getUserId(); - - const powerLevels = await this.client.getRoomStateEvent(roomId, "m.room.power_levels", ""); - if (!powerLevels) { - // noinspection ExceptionCaughtLocallyJS - throw new Error("Missing power levels state event"); - } - - function plDefault(val: number | undefined | null, def: number): number { - if (!val && val !== 0) return def; - return val; - } - - const users = powerLevels['users'] || {}; - const events = powerLevels['events'] || {}; - const usersDefault = plDefault(powerLevels['users_default'], 0); - const stateDefault = plDefault(powerLevels['state_default'], 50); - const ban = plDefault(powerLevels['ban'], 50); - const kick = plDefault(powerLevels['kick'], 50); - const redact = plDefault(powerLevels['redact'], 50); - - const userLevel = plDefault(users[ownUserId], usersDefault); - const aclLevel = plDefault(events["m.room.server_acl"], stateDefault); - - // Wants: ban, kick, redact, m.room.server_acl - - if (userLevel < ban) { - errors.push({ - roomId, - errorMessage: `Missing power level for bans: ${userLevel} < ${ban}`, - errorKind: ERROR_KIND_PERMISSION, - }); - } - if (userLevel < kick) { - errors.push({ - roomId, - errorMessage: `Missing power level for kicks: ${userLevel} < ${kick}`, - errorKind: ERROR_KIND_PERMISSION, - }); - } - if (userLevel < redact) { - errors.push({ - roomId, - errorMessage: `Missing power level for redactions: ${userLevel} < ${redact}`, - errorKind: ERROR_KIND_PERMISSION, - }); - } - if (userLevel < aclLevel) { - errors.push({ - roomId, - errorMessage: `Missing power level for server ACLs: ${userLevel} < ${aclLevel}`, - errorKind: ERROR_KIND_PERMISSION, - }); - } - - // Wants: Additional permissions - - for (const additionalPermission of additionalPermissions) { - const permLevel = plDefault(events[additionalPermission], stateDefault); - - if (userLevel < permLevel) { - errors.push({ - roomId, - errorMessage: `Missing power level for "${additionalPermission}" state events: ${userLevel} < ${permLevel}`, - errorKind: ERROR_KIND_PERMISSION, - }); - } - } - - // Otherwise OK - } catch (e) { - LogService.error("Mjolnir", extractRequestError(e)); - errors.push({ - roomId, - errorMessage: e.message || (e.body ? e.body.error : ''), - errorKind: ERROR_KIND_FATAL, - }); + public addUnprotectedWatchedListRoom(roomId: string) { + if (this._unprotectedWatchedListRooms.includes(roomId)) { + return; } - - return errors; + this._unprotectedWatchedListRooms.push(roomId); + this.removeProtectedRoom(roomId); } } \ No newline at end of file diff --git a/src/commands/AddRemoveProtectedRoomsCommand.ts b/src/commands/AddRemoveProtectedRoomsCommand.ts index d8d5832..943cdb6 100644 --- a/src/commands/AddRemoveProtectedRoomsCommand.ts +++ b/src/commands/AddRemoveProtectedRoomsCommand.ts @@ -20,19 +20,19 @@ import { extractRequestError, LogLevel, LogService } from "matrix-bot-sdk"; // !mjolnir rooms add export async function execAddProtectedRoom(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { const protectedRoomId = await mjolnir.client.joinRoom(parts[3]); - await mjolnir.addProtectedRoom(protectedRoomId); + await mjolnir.protectedRoomsTracker.addProtectedRoom(protectedRoomId); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); } // !mjolnir rooms remove export async function execRemoveProtectedRoom(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { const protectedRoomId = await mjolnir.client.resolveRoom(parts[3]); - await mjolnir.removeProtectedRoom(protectedRoomId); + await mjolnir.protectedRoomsTracker.removeProtectedRoom(protectedRoomId); try { await mjolnir.client.leaveRoom(protectedRoomId); } catch (e) { LogService.warn("AddRemoveProtectedRoomsCommand", extractRequestError(e)); - await mjolnir.logMessage(LogLevel.WARN, "AddRemoveProtectedRoomsCommand", `Failed to leave ${protectedRoomId} - the room is no longer being protected, but the bot could not leave`, protectedRoomId); + await mjolnir.managementRoom.logMessage(LogLevel.WARN, "AddRemoveProtectedRoomsCommand", `Failed to leave ${protectedRoomId} - the room is no longer being protected, but the bot could not leave`, protectedRoomId); } await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); } diff --git a/src/commands/KickCommand.ts b/src/commands/KickCommand.ts index dde837d..aef8c6f 100644 --- a/src/commands/KickCommand.ts +++ b/src/commands/KickCommand.ts @@ -22,7 +22,7 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln let force = false; const glob = parts[2]; - let rooms = [...Object.keys(mjolnir.protectedRooms)]; + let rooms = [...Object.keys(mjolnir.protectedRoomsTracker.protectedRooms)]; if (parts[parts.length - 1] === "--force") { force = true; @@ -57,7 +57,7 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln const victim = member.membershipFor; if (kickRule.test(victim)) { - await mjolnir.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId); + await mjolnir.managementRoom.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId); if (!mjolnir.config.noop) { try { @@ -65,10 +65,10 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln return mjolnir.client.kickUser(victim, protectedRoomId, reason); }); } catch (e) { - await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`); + await mjolnir.managementRoom.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`); } } else { - await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${victim} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId); + await mjolnir.managementRoom.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${victim} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId); } } } diff --git a/src/commands/ListProtectedRoomsCommand.ts b/src/commands/ListProtectedRoomsCommand.ts index abd6e15..3373185 100644 --- a/src/commands/ListProtectedRoomsCommand.ts +++ b/src/commands/ListProtectedRoomsCommand.ts @@ -15,18 +15,19 @@ limitations under the License. */ import { Mjolnir } from "../Mjolnir"; -import { RichReply } from "matrix-bot-sdk"; +import { Permalinks, RichReply } from "matrix-bot-sdk"; // !mjolnir rooms export async function execListProtectedRooms(roomId: string, event: any, mjolnir: Mjolnir) { - let html = `Protected rooms (${Object.keys(mjolnir.protectedRooms).length}):
    `; - let text = `Protected rooms (${Object.keys(mjolnir.protectedRooms).length}):\n`; + const rooms = [...mjolnir.protectedRoomsTracker.protectedRooms]; + let html = `Protected rooms (${rooms.length}):
      `; + let text = `Protected rooms (${rooms.length}):\n`; let hasRooms = false; - for (const protectedRoomId in mjolnir.protectedRooms) { + for (const protectedRoomId in rooms) { hasRooms = true; - const roomUrl = mjolnir.protectedRooms[protectedRoomId]; + const roomUrl = Permalinks.forRoom(protectedRoomId); html += `
    • ${protectedRoomId}
    • `; text += `* ${roomUrl}\n`; } diff --git a/src/commands/PermissionCheckCommand.ts b/src/commands/PermissionCheckCommand.ts index d3459f1..a90983d 100644 --- a/src/commands/PermissionCheckCommand.ts +++ b/src/commands/PermissionCheckCommand.ts @@ -18,5 +18,5 @@ import { Mjolnir } from "../Mjolnir"; // !mjolnir verify export async function execPermissionCheckCommand(roomId: string, event: any, mjolnir: Mjolnir) { - return mjolnir.verifyPermissions(true, true); + return mjolnir.protectedRoomsTracker.verifyPermissions(true, true); } diff --git a/src/commands/ProtectionsCommands.ts b/src/commands/ProtectionsCommands.ts index 99b3dc4..8df7ee9 100644 --- a/src/commands/ProtectionsCommands.ts +++ b/src/commands/ProtectionsCommands.ts @@ -22,7 +22,7 @@ import { isListSetting } from "../protections/ProtectionSettings"; // !mjolnir enable export async function execEnableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { try { - await mjolnir.enableProtection(parts[2]); + await mjolnir.protectionManager.enableProtection(parts[2]); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); } catch (e) { LogService.error("ProtectionsCommands", extractRequestError(e)); @@ -50,8 +50,8 @@ enum ConfigAction { */ async function _execConfigChangeProtection(mjolnir: Mjolnir, parts: string[], action: ConfigAction): Promise { const [protectionName, ...settingParts] = parts[0].split("."); - const protection = mjolnir.protections.get(protectionName); - if (protection === undefined) { + const protection = mjolnir.protectionManager.getProtection(protectionName); + if (!protection) { return `Unknown protection ${protectionName}`; } @@ -83,7 +83,7 @@ async function _execConfigChangeProtection(mjolnir: Mjolnir, parts: string[], ac } try { - await mjolnir.setProtectionSettings(protectionName, { [settingName]: value }); + await mjolnir.protectionManager.setProtectionSettings(protectionName, { [settingName]: value }); } catch (e) { return `Failed to set setting: ${e.message}`; } @@ -139,7 +139,7 @@ export async function execConfigRemoveProtection(roomId: string, event: any, mjo * !mjolnir get [protection name] */ export async function execConfigGetProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - let pickProtections = Object.keys(mjolnir.protections); + let pickProtections = Object.keys(mjolnir.protectionManager.protections); if (parts.length < 3) { // no specific protectionName provided, show all of them. @@ -163,7 +163,7 @@ export async function execConfigGetProtection(roomId: string, event: any, mjolni let anySettings = false; for (const protectionName of pickProtections) { - const protectionSettings = mjolnir.protections.get(protectionName)?.settings ?? {}; + const protectionSettings = mjolnir.protectionManager.getProtection(protectionName)?.settings ?? {}; if (Object.keys(protectionSettings).length === 0) { continue; @@ -196,18 +196,18 @@ export async function execConfigGetProtection(roomId: string, event: any, mjolni // !mjolnir disable export async function execDisableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - await mjolnir.disableProtection(parts[2]); + await mjolnir.protectionManager.disableProtection(parts[2]); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); } // !mjolnir protections export async function execListProtections(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const enabledProtections = mjolnir.enabledProtections.map(p => p.name); + const enabledProtections = mjolnir.protectionManager.enabledProtections.map(p => p.name); let html = "Available protections:
        "; let text = "Available protections:\n"; - for (const [protectionName, protection] of mjolnir.protections) { + for (const [protectionName, protection] of mjolnir.protectionManager.protections) { const emoji = enabledProtections.includes(protectionName) ? '🟢 (enabled)' : '🔴 (disabled)'; html += `
      • ${emoji} ${protectionName} - ${protection.description}
      • `; text += `* ${emoji} ${protectionName} - ${protection.description}\n`; diff --git a/src/commands/RedactCommand.ts b/src/commands/RedactCommand.ts index bfc980e..cfbf25a 100644 --- a/src/commands/RedactCommand.ts +++ b/src/commands/RedactCommand.ts @@ -45,8 +45,8 @@ export async function execRedactCommand(roomId: string, event: any, mjolnir: Mjo return; } - const targetRoomIds = roomAlias ? [roomAlias] : Object.keys(mjolnir.protectedRooms); - await redactUserMessagesIn(mjolnir, userId, targetRoomIds, limit); + const targetRoomIds = roomAlias ? [roomAlias] : [...mjolnir.protectedRoomsTracker.protectedRooms]; + await redactUserMessagesIn(mjolnir.client, mjolnir.managementRoom, userId, targetRoomIds, limit); await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); await mjolnir.client.redactEvent(roomId, processingReactionId, 'done processing'); diff --git a/src/commands/SetPowerLevelCommand.ts b/src/commands/SetPowerLevelCommand.ts index 5c46f49..881c837 100644 --- a/src/commands/SetPowerLevelCommand.ts +++ b/src/commands/SetPowerLevelCommand.ts @@ -23,14 +23,14 @@ export async function execSetPowerLevelCommand(roomId: string, event: any, mjoln const level = Math.round(Number(parts[3])); const inRoom = parts[4]; - let targetRooms = inRoom ? [await mjolnir.client.resolveRoom(inRoom)] : Object.keys(mjolnir.protectedRooms); + let targetRooms = inRoom ? [await mjolnir.client.resolveRoom(inRoom)] : [...mjolnir.protectedRoomsTracker.protectedRooms]; for (const targetRoomId of targetRooms) { try { await mjolnir.client.setUserPowerLevel(victim, targetRoomId, level); } catch (e) { const message = e.message || (e.body ? e.body.error : ''); - await mjolnir.logMessage(LogLevel.ERROR, "SetPowerLevelCommand", `Failed to set power level of ${victim} to ${level} in ${targetRoomId}: ${message}`, targetRoomId); + await mjolnir.managementRoom.logMessage(LogLevel.ERROR, "SetPowerLevelCommand", `Failed to set power level of ${victim} to ${level} in ${targetRoomId}: ${message}`, targetRoomId); LogService.error("SetPowerLevelCommand", extractRequestError(e)); } } diff --git a/src/commands/SinceCommand.ts b/src/commands/SinceCommand.ts index 879a42e..47274ec 100644 --- a/src/commands/SinceCommand.ts +++ b/src/commands/SinceCommand.ts @@ -100,7 +100,7 @@ export async function execSinceCommand(destinationRoomId: string, event: any, mj let result = await execSinceCommandAux(destinationRoomId, event, mjolnir, tokens); if ("error" in result) { mjolnir.client.unstableApis.addReactionToEvent(destinationRoomId, event['event_id'], '❌'); - mjolnir.logMessage(LogLevel.WARN, "SinceCommand", result.error); + mjolnir.managementRoom.logMessage(LogLevel.WARN, "SinceCommand", result.error); const reply = RichReply.createFor(destinationRoomId, event, result.error, htmlEscape(result.error)); reply["msgtype"] = "m.notice"; /* no need to await */ mjolnir.client.sendMessage(destinationRoomId, reply); @@ -185,6 +185,7 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni // Now list affected rooms. const rooms: Set = new Set(); let reasonParts: string[] | undefined; + const protectedRooms = new Set(mjolnir.protectedRoomsTracker.protectedRooms); for (let token of optionalTokens) { const maybeArg = getTokenAsString(reasonParts ? "[reason]" : "[room]", token); if ("error" in maybeArg) { @@ -194,14 +195,14 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni if (!reasonParts) { // If we haven't reached the reason yet, attempt to use `maybeRoom` as a room. if (maybeRoom === "*") { - for (let roomId of Object.keys(mjolnir.protectedRooms)) { + for (let roomId of mjolnir.protectedRoomsTracker.protectedRooms) { rooms.add(roomId); } continue; } else if (maybeRoom.startsWith("#") || maybeRoom.startsWith("!")) { const roomId = await mjolnir.client.resolveRoom(maybeRoom); - if (!(roomId in mjolnir.protectedRooms)) { - return mjolnir.logMessage(LogLevel.WARN, "SinceCommand", `This room is not protected: ${htmlEscape(roomId)}.`); + if (!protectedRooms.has(roomId)) { + return mjolnir.managementRoom.logMessage(LogLevel.WARN, "SinceCommand", `This room is not protected: ${htmlEscape(roomId)}.`); } rooms.add(roomId); continue; @@ -225,7 +226,7 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni for (let targetRoomId of rooms) { let {html, text} = await (async () => { let results: Summary = { succeeded: [], failed: []}; - const recentJoins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries); + const recentJoins = mjolnir.protectedRoomsTracker.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries); switch (action) { case Action.Show: { diff --git a/src/commands/StatusCommand.ts b/src/commands/StatusCommand.ts index 987c133..963f6d7 100644 --- a/src/commands/StatusCommand.ts +++ b/src/commands/StatusCommand.ts @@ -67,8 +67,9 @@ async function showMjolnirStatus(roomId: string, event: any, mjolnir: Mjolnir) { break; } - html += `Protected rooms: ${Object.keys(mjolnir.protectedRooms).length}
        `; - text += `Protected rooms: ${Object.keys(mjolnir.protectedRooms).length}\n`; + const protectedRooms = [...mjolnir.protectedRoomsTracker.protectedRooms]; + html += `Protected rooms: ${protectedRooms.length}
        `; + text += `Protected rooms: ${protectedRooms.length}\n`; // Append list information html += "Subscribed ban lists:
          "; @@ -91,7 +92,7 @@ async function showMjolnirStatus(roomId: string, event: any, mjolnir: Mjolnir) { async function showProtectionStatus(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { const protectionName = parts[0]; - const protection = mjolnir.getProtection(protectionName); + const protection = mjolnir.protectionManager.getProtection(protectionName); let text; let html; if (!protection) { @@ -156,7 +157,7 @@ async function showJoinsStatus(destinationRoomId: string, event: any, mjolnir: M text: `Cannot resolve room \`${targetRoomAliasOrId}\`.` } } - const joins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries); + const joins = mjolnir.protectedRoomsTracker.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries); const htmlFragments = []; const textFragments = []; for (let join of joins) { diff --git a/src/commands/SyncCommand.ts b/src/commands/SyncCommand.ts index 0ebb174..995d31c 100644 --- a/src/commands/SyncCommand.ts +++ b/src/commands/SyncCommand.ts @@ -18,5 +18,5 @@ import { Mjolnir } from "../Mjolnir"; // !mjolnir sync export async function execSyncCommand(roomId: string, event: any, mjolnir: Mjolnir) { - return mjolnir.syncLists(); + return mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); } diff --git a/src/commands/UnbanBanCommand.ts b/src/commands/UnbanBanCommand.ts index 3ea6ee0..d1536a2 100644 --- a/src/commands/UnbanBanCommand.ts +++ b/src/commands/UnbanBanCommand.ts @@ -138,21 +138,21 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol if (USER_RULE_TYPES.includes(bits.ruleType!) && bits.reason === 'true') { const rule = new MatrixGlob(bits.entity); - await mjolnir.logMessage(LogLevel.INFO, "UnbanBanCommand", "Unbanning users that match glob: " + bits.entity); + await mjolnir.managementRoom.logMessage(LogLevel.INFO, "UnbanBanCommand", "Unbanning users that match glob: " + bits.entity); let unbannedSomeone = false; - for (const protectedRoomId of Object.keys(mjolnir.protectedRooms)) { + for (const protectedRoomId of mjolnir.protectedRoomsTracker.protectedRooms) { const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ['ban'], undefined); - await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Found ${members.length} banned user(s)`); + await mjolnir.managementRoom.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Found ${members.length} banned user(s)`); for (const member of members) { const victim = member.membershipFor; if (member.membership !== 'ban') continue; if (rule.test(victim)) { - await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Unbanning ${victim} in ${protectedRoomId}`, protectedRoomId); + await mjolnir.managementRoom.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Unbanning ${victim} in ${protectedRoomId}`, protectedRoomId); if (!mjolnir.config.noop) { await mjolnir.client.unbanUser(victim, protectedRoomId); } else { - await mjolnir.logMessage(LogLevel.WARN, "UnbanBanCommand", `Attempted to unban ${victim} in ${protectedRoomId} but Mjolnir is running in no-op mode`, protectedRoomId); + await mjolnir.managementRoom.logMessage(LogLevel.WARN, "UnbanBanCommand", `Attempted to unban ${victim} in ${protectedRoomId} but Mjolnir is running in no-op mode`, protectedRoomId); } unbannedSomeone = true; @@ -161,8 +161,8 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol } if (unbannedSomeone) { - await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Syncing lists to ensure no users were accidentally unbanned`); - await mjolnir.syncLists(mjolnir.config.verboseLogging); + await mjolnir.managementRoom.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Syncing lists to ensure no users were accidentally unbanned`); + await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); } } diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index 66c82fa..26bf225 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -62,11 +62,11 @@ export class BasicFlooding extends Protection { } if (messageCount >= this.settings.maxPerMinute.value) { - await mjolnir.logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`, roomId); + await mjolnir.managementRoom.logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`, roomId); if (!mjolnir.config.noop) { await mjolnir.client.banUser(event['sender'], roomId, "spam"); } else { - await mjolnir.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); + await mjolnir.managementRoom.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); } if (this.recentlyBanned.includes(event['sender'])) return; // already handled (will be redacted) @@ -79,7 +79,7 @@ export class BasicFlooding extends Protection { await mjolnir.client.redactEvent(roomId, eventId, "spam"); } } else { - await mjolnir.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to redact messages for ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); + await mjolnir.managementRoom.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to redact messages for ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); } // Free up some memory now that we're ready to handle it elsewhere diff --git a/src/protections/DetectFederationLag.ts b/src/protections/DetectFederationLag.ts index 548bf65..2ed58bc 100644 --- a/src/protections/DetectFederationLag.ts +++ b/src/protections/DetectFederationLag.ts @@ -627,7 +627,7 @@ export class DetectFederationLag extends Protection { roomInfo.latestAlertStart = now; // Background-send message. const stats = roomInfo.globalStats(); - /* do not await */ mjolnir.logMessage(LogLevel.WARN, "FederationLag", + /* do not await */ mjolnir.managementRoom.logMessage(LogLevel.WARN, "FederationLag", `Room ${roomId} is experiencing ${isLocalDomainOnAlert ? "LOCAL" : "federated"} lag since ${roomInfo.latestAlertStart}.\n${roomInfo.alerts} homeservers are lagging: ${[...roomInfo.serversOnAlert()].sort()} .\nRoom lag statistics: ${JSON.stringify(stats, null, 2)}.`); // Drop a state event, for the use of potential other bots. const warnStateEventId = await mjolnir.client.sendStateEvent(mjolnir.managementRoomId, LAG_STATE_EVENT, roomId, { @@ -642,7 +642,7 @@ export class DetectFederationLag extends Protection { } else if (roomInfo.alerts < this.settings.numberOfLaggingFederatedHomeserversExitWarningZone.value || !isLocalDomainOnAlert) { // Stop the alarm! - /* do not await */ mjolnir.logMessage(LogLevel.INFO, "FederationLag", + /* do not await */ mjolnir.managementRoom.logMessage(LogLevel.INFO, "FederationLag", `Room ${roomId} lag has decreased to an acceptable level. Currently, ${roomInfo.alerts} homeservers are still lagging` ); if (roomInfo.warnStateEventId) { diff --git a/src/protections/FirstMessageIsImage.ts b/src/protections/FirstMessageIsImage.ts index a1cc588..b88004d 100644 --- a/src/protections/FirstMessageIsImage.ts +++ b/src/protections/FirstMessageIsImage.ts @@ -56,11 +56,11 @@ export class FirstMessageIsImage extends Protection { const formattedBody = content['formatted_body'] || ''; const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || formattedBody.toLowerCase().includes('= this.settings.maxPer.value) { - await mjolnir.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Setting ${roomId} to invite-only as more than ${this.settings.maxPer.value} users have joined over the last ${this.settings.timescaleMinutes.value} minutes (since ${this.joinBuckets[roomId].lastBucketStart})`, roomId); + await mjolnir.managementRoom.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Setting ${roomId} to invite-only as more than ${this.settings.maxPer.value} users have joined over the last ${this.settings.timescaleMinutes.value} minutes (since ${this.joinBuckets[roomId].lastBucketStart})`, roomId); if (!mjolnir.config.noop) { await mjolnir.client.sendStateEvent(roomId, "m.room.join_rules", "", {"join_rule": "invite"}) } else { - await mjolnir.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Tried to set ${roomId} to invite-only, but Mjolnir is running in no-op mode`, roomId); + await mjolnir.managementRoom.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Tried to set ${roomId} to invite-only, but Mjolnir is running in no-op mode`, roomId); } } } diff --git a/src/protections/MessageIsMedia.ts b/src/protections/MessageIsMedia.ts index 6d4c759..6cd64be 100644 --- a/src/protections/MessageIsMedia.ts +++ b/src/protections/MessageIsMedia.ts @@ -40,12 +40,12 @@ export class MessageIsMedia extends Protection { const formattedBody = content['formatted_body'] || ''; const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || formattedBody.toLowerCase().includes('(); + get protections(): Readonly> { + return this._protections; + } + + + constructor(private readonly mjolnir: Mjolnir) { + } + + /* + * Take all the builtin protections, register them to set their enabled (or not) state and + * update their settings with any saved non-default values + */ + public async start() { + this.mjolnir.reportManager.on("report.new", this.handleReport.bind(this)); + this.mjolnir.client.on("room.event", this.handleEvent.bind(this)); + for (const protection of PROTECTIONS) { + try { + await this.registerProtection(protection); + } catch (e) { + this.mjolnir.managementRoom.logMessage(LogLevel.WARN, "ProtectionManager", extractRequestError(e)); + } + } + } + + /* + * Given a protection object; add it to our list of protections, set whether it is enabled + * and update its settings with any saved non-default values. + * + * @param protection The protection object we want to register + */ + public async registerProtection(protection: Protection) { + this._protections.set(protection.name, protection) + + let enabledProtections: { enabled: string[] } | null = null; + try { + enabledProtections = await this.mjolnir.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE); + } catch { + // this setting either doesn't exist, or we failed to read it (bad network?) + // TODO: retry on certain failures? + } + protection.enabled = enabledProtections?.enabled.includes(protection.name) ?? false; + + const savedSettings = await this.getProtectionSettings(protection.name); + for (let [key, value] of Object.entries(savedSettings)) { + // this.getProtectionSettings() validates this data for us, so we don't need to + protection.settings[key].setValue(value); + } + } + + /* + * Given a protection object; remove it from our list of protections. + * + * @param protection The protection object we want to unregister + */ + public unregisterProtection(protectionName: string) { + if (!(protectionName in this._protections)) { + throw new Error("Failed to find protection by name: " + protectionName); + } + this._protections.delete(protectionName); + } + + /* + * Takes an object of settings we want to change and what their values should be, + * check that their values are valid, combine them with current saved settings, + * then save the amalgamation to a state event + * + * @param protectionName Which protection these settings belong to + * @param changedSettings The settings to change and their values + */ + public async setProtectionSettings(protectionName: string, changedSettings: { [setting: string]: any }): Promise { + const protection = this._protections.get(protectionName); + if (protection === undefined) { + return; + } + + const validatedSettings: { [setting: string]: any } = await this.getProtectionSettings(protectionName); + + for (let [key, value] of Object.entries(changedSettings)) { + if (!(key in protection.settings)) { + throw new ProtectionSettingValidationError(`Failed to find protection setting by name: ${key}`); + } + if (typeof (protection.settings[key].value) !== typeof (value)) { + throw new ProtectionSettingValidationError(`Invalid type for protection setting: ${key} (${typeof (value)})`); + } + if (!protection.settings[key].validate(value)) { + throw new ProtectionSettingValidationError(`Invalid value for protection setting: ${key} (${value})`); + } + validatedSettings[key] = value; + } + + await this.mjolnir.client.sendStateEvent( + this.mjolnir.managementRoomId, 'org.matrix.mjolnir.setting', protectionName, validatedSettings + ); + } + + /* + * Make a list of the names of enabled protections and save them in a state event + */ + private async saveEnabledProtections() { + const protections = this.enabledProtections.map(p => p.name); + await this.mjolnir.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: protections }); + } + /* + * Enable a protection by name and persist its enable state in to a state event + * + * @param name The name of the protection whose settings we're enabling + */ + public async enableProtection(name: string) { + const protection = this._protections.get(name); + if (protection !== undefined) { + protection.enabled = true; + await this.saveEnabledProtections(); + } + } + + public get enabledProtections(): Protection[] { + return [...this._protections.values()].filter(p => p.enabled); + } + + /** + * Get a protection by name. + * + * @return If there is a protection with this name *and* it is enabled, + * return the protection. + */ + public getProtection(protectionName: string): Protection | null { + return this._protections.get(protectionName) ?? null; + } + + /* + * Disable a protection by name and remove it from the persistent list of enabled protections + * + * @param name The name of the protection whose settings we're disabling + */ + public async disableProtection(name: string) { + const protection = this._protections.get(name); + if (protection !== undefined) { + protection.enabled = false; + await this.saveEnabledProtections(); + } + } + + /* + * Read org.matrix.mjolnir.setting state event, find any saved settings for + * the requested protectionName, then iterate and validate against their parser + * counterparts in Protection.settings and return those which validate + * + * @param protectionName The name of the protection whose settings we're reading + * @returns Every saved setting for this protectionName that has a valid value + */ + public async getProtectionSettings(protectionName: string): Promise<{ [setting: string]: any }> { + let savedSettings: { [setting: string]: any } = {} + try { + savedSettings = await this.mjolnir.client.getRoomStateEvent( + this.mjolnir.managementRoomId, 'org.matrix.mjolnir.setting', protectionName + ); + } catch { + // setting does not exist, return empty object + return {}; + } + + const settingDefinitions = this._protections.get(protectionName)?.settings ?? {}; + const validatedSettings: { [setting: string]: any } = {} + for (let [key, value] of Object.entries(savedSettings)) { + if ( + // is this a setting name with a known parser? + key in settingDefinitions + // is the datatype of this setting's value what we expect? + && typeof (settingDefinitions[key].value) === typeof (value) + // is this setting's value valid for the setting? + && settingDefinitions[key].validate(value) + ) { + validatedSettings[key] = value; + } else { + await this.mjolnir.managementRoom.logMessage( + LogLevel.WARN, + "getProtectionSetting", + `Tried to read ${protectionName}.${key} and got invalid value ${value}` + ); + } + } + return validatedSettings; + } + + private async handleConsequence(protection: Protection, roomId: string, eventId: string, sender: string, consequence: Consequence) { + switch (consequence.type) { + case ConsequenceType.alert: + break; + case ConsequenceType.redact: + await this.mjolnir.client.redactEvent(roomId, eventId, "abuse detected"); + break; + case ConsequenceType.ban: + await this.mjolnir.client.banUser(sender, roomId, "abuse detected"); + break; + } + + let message = `protection ${protection.name} enacting ${ConsequenceType[consequence.type]}` + + ` against ${htmlEscape(sender)}` + + ` in ${htmlEscape(roomId)}`; + if (consequence.reason !== undefined) { + // even though internally-sourced, there's no promise that `consequence.reason` + // will never have user-supplied information, so escape it + message += ` (reason: ${htmlEscape(consequence.reason)})`; + } + + await this.mjolnir.client.sendMessage(this.mjolnir.managementRoomId, { + msgtype: "m.notice", + body: message, + [CONSEQUENCE_EVENT_DATA]: { + who: sender, + room: roomId, + type: ConsequenceType[consequence.type] + } + }); + } + + private async handleEvent(roomId: string, event: any) { + if (roomId in this.mjolnir.protectedRoomsTracker.protectedRooms) { + if (event['sender'] === await this.mjolnir.client.getUserId()) return; // Ignore ourselves + + // Iterate all the enabled protections + for (const protection of this.enabledProtections) { + let consequence: Consequence | undefined = undefined; + try { + consequence = await protection.handleEvent(this.mjolnir, roomId, event); + } catch (e) { + const eventPermalink = Permalinks.forEvent(roomId, event['event_id']); + LogService.error("ProtectionManager", "Error handling protection: " + protection.name); + LogService.error("ProtectionManager", "Failed event: " + eventPermalink); + LogService.error("ProtectionManager", extractRequestError(e)); + await this.mjolnir.client.sendNotice(this.mjolnir.managementRoomId, "There was an error processing an event through a protection - see log for details. Event: " + eventPermalink); + continue; + } + + if (consequence !== undefined) { + await this.handleConsequence(protection, roomId, event["event_id"], event["sender"], consequence); + } + } + + // Run the event handlers - we always run this after protections so that the protections + // can flag the event for redaction. + await this.mjolnir.unlistedUserRedactionHandler.handleEvent(roomId, event, this.mjolnir); // FIXME: That's rather spaghetti + } + } + + + private requiredProtectionPermissions(): Set { + return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat()) + } + + public async verifyPermissionsIn(roomId: string): Promise { + const errors: RoomUpdateError[] = []; + const additionalPermissions = this.requiredProtectionPermissions(); + + try { + const ownUserId = await this.mjolnir.client.getUserId(); + + const powerLevels = await this.mjolnir.client.getRoomStateEvent(roomId, "m.room.power_levels", ""); + if (!powerLevels) { + // noinspection ExceptionCaughtLocallyJS + throw new Error("Missing power levels state event"); + } + + function plDefault(val: number | undefined | null, def: number): number { + if (!val && val !== 0) return def; + return val; + } + + const users = powerLevels['users'] || {}; + const events = powerLevels['events'] || {}; + const usersDefault = plDefault(powerLevels['users_default'], 0); + const stateDefault = plDefault(powerLevels['state_default'], 50); + const ban = plDefault(powerLevels['ban'], 50); + const kick = plDefault(powerLevels['kick'], 50); + const redact = plDefault(powerLevels['redact'], 50); + + const userLevel = plDefault(users[ownUserId], usersDefault); + const aclLevel = plDefault(events["m.room.server_acl"], stateDefault); + + // Wants: ban, kick, redact, m.room.server_acl + + if (userLevel < ban) { + errors.push({ + roomId, + errorMessage: `Missing power level for bans: ${userLevel} < ${ban}`, + errorKind: ERROR_KIND_PERMISSION, + }); + } + if (userLevel < kick) { + errors.push({ + roomId, + errorMessage: `Missing power level for kicks: ${userLevel} < ${kick}`, + errorKind: ERROR_KIND_PERMISSION, + }); + } + if (userLevel < redact) { + errors.push({ + roomId, + errorMessage: `Missing power level for redactions: ${userLevel} < ${redact}`, + errorKind: ERROR_KIND_PERMISSION, + }); + } + if (userLevel < aclLevel) { + errors.push({ + roomId, + errorMessage: `Missing power level for server ACLs: ${userLevel} < ${aclLevel}`, + errorKind: ERROR_KIND_PERMISSION, + }); + } + + // Wants: Additional permissions + + for (const additionalPermission of additionalPermissions) { + const permLevel = plDefault(events[additionalPermission], stateDefault); + + if (userLevel < permLevel) { + errors.push({ + roomId, + errorMessage: `Missing power level for "${additionalPermission}" state events: ${userLevel} < ${permLevel}`, + errorKind: ERROR_KIND_PERMISSION, + }); + } + } + + // Otherwise OK + } catch (e) { + LogService.error("Mjolnir", extractRequestError(e)); + errors.push({ + roomId, + errorMessage: e.message || (e.body ? e.body.error : ''), + errorKind: ERROR_KIND_FATAL, + }); + } + + return errors; + } + + // FIXME: Hook up + private async handleReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) { + for (const protection of this.enabledProtections) { + await protection.handleReport(this.mjolnir, roomId, reporterId, event, reason); + } + } +} \ No newline at end of file diff --git a/src/queues/EventRedactionQueue.ts b/src/queues/EventRedactionQueue.ts index d435931..f7b3ff3 100644 --- a/src/queues/EventRedactionQueue.ts +++ b/src/queues/EventRedactionQueue.ts @@ -17,7 +17,6 @@ import { LogLevel, MatrixClient } from "matrix-bot-sdk" import { ERROR_KIND_FATAL } from "../ErrorCache"; import { RoomUpdateError } from "../models/RoomUpdateError"; import { redactUserMessagesIn } from "../utils"; -import { Mjolnir } from "../Mjolnir"; import ManagementRoomOutput from "../ManagementRoom"; export interface QueuedRedaction { diff --git a/src/queues/ThrottlingQueue.ts b/src/queues/ThrottlingQueue.ts index b63c9c8..43d0445 100644 --- a/src/queues/ThrottlingQueue.ts +++ b/src/queues/ThrottlingQueue.ts @@ -178,7 +178,7 @@ export class ThrottlingQueue { try { await task(); } catch (ex) { - await this.mjolnir.logMessage( + await this.mjolnir.managementRoom.logMessage( LogLevel.WARN, 'Error while executing task', extractRequestError(ex) diff --git a/src/queues/UnlistedUserRedactionQueue.ts b/src/queues/UnlistedUserRedactionQueue.ts index 91ac878..6fb33ec 100644 --- a/src/queues/UnlistedUserRedactionQueue.ts +++ b/src/queues/UnlistedUserRedactionQueue.ts @@ -45,10 +45,10 @@ export class UnlistedUserRedactionQueue { if (!mjolnir.config.noop) { await mjolnir.client.redactEvent(roomId, event['event_id']); } else { - await mjolnir.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Tried to redact ${permalink} but Mjolnir is running in no-op mode`); + await mjolnir.managementRoom.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Tried to redact ${permalink} but Mjolnir is running in no-op mode`); } } catch (e) { - mjolnir.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Unable to redact message: ${permalink}`); + mjolnir.managementRoom.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Unable to redact message: ${permalink}`); LogService.warn("AutomaticRedactionQueue", extractRequestError(e)); } } diff --git a/src/report/ReportPoller.ts b/src/report/ReportPoller.ts index 2e98833..6203b2b 100644 --- a/src/report/ReportPoller.ts +++ b/src/report/ReportPoller.ts @@ -75,13 +75,13 @@ export class ReportPoller { } ); } catch (ex) { - await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to poll events: ${ex}`); + await this.mjolnir.managementRoom.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to poll events: ${ex}`); return; } const response = response_!; for (let report of response.event_reports) { - if (!(report.room_id in this.mjolnir.protectedRooms)) { + if (!this.mjolnir.protectedRoomsTracker.isProtectedRoom(report.room_id)) { continue; } @@ -92,7 +92,7 @@ export class ReportPoller { `/_synapse/admin/v1/rooms/${report.room_id}/context/${report.event_id}?limit=1` )).event; } catch (ex) { - this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to get context: ${ex}`); + this.mjolnir.managementRoom.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to get context: ${ex}`); continue; } @@ -114,7 +114,7 @@ export class ReportPoller { try { await this.mjolnir.client.setAccountData(REPORT_POLL_EVENT_TYPE, { from: response.next_token }); } catch (ex) { - await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to update progress: ${ex}`); + await this.mjolnir.managementRoom.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to update progress: ${ex}`); } } } @@ -125,7 +125,7 @@ export class ReportPoller { try { await this.getAbuseReports() } catch (ex) { - await this.mjolnir.logMessage(LogLevel.ERROR, "tryGetAbuseReports", `failed to get abuse reports: ${ex}`); + await this.mjolnir.managementRoom.logMessage(LogLevel.ERROR, "tryGetAbuseReports", `failed to get abuse reports: ${ex}`); } this.schedulePoll(); diff --git a/src/utils.ts b/src/utils.ts index c3ea51b..fb47aa4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -15,19 +15,13 @@ limitations under the License. */ import { - extractRequestError, LogLevel, LogService, MatrixClient, MatrixGlob, - MessageType, - Permalinks, - TextualMessageEventContent, - UserID, getRequestFn, setRequestFn, } from "matrix-bot-sdk"; -import { Mjolnir } from "./Mjolnir"; import { ClientRequest, IncomingMessage } from "http"; import { default as parseDuration } from "parse-duration"; import ManagementRoomOutput from "./ManagementRoom"; diff --git a/test/integration/abuseReportTest.ts b/test/integration/abuseReportTest.ts index e3c58b7..2bbb9bb 100644 --- a/test/integration/abuseReportTest.ts +++ b/test/integration/abuseReportTest.ts @@ -24,8 +24,8 @@ describe("Test: Reporting abuse", async () => { this.timeout(60000); // Listen for any notices that show up. - let notices = []; - matrixClient().on("room.event", (roomId, event) => { + let notices: any[] = []; + matrixClient()!.on("room.event", (roomId, event) => { if (roomId = this.mjolnir.managementRoomId) { notices.push(event); } diff --git a/test/integration/banListTest.ts b/test/integration/banListTest.ts index 058c301..b5144cc 100644 --- a/test/integration/banListTest.ts +++ b/test/integration/banListTest.ts @@ -242,12 +242,12 @@ describe('Test: ACL updates will batch when rules are added in succession.', fun const room = await moderator.createRoom({ invite: [mjolnirId] }); await mjolnir.client.joinRoom(room); await moderator.setUserPowerLevel(mjolnirId, room, 100); - await mjolnir.addProtectedRoom(room); + await mjolnir.protectedRoomsTracker.addProtectedRoom(room); protectedRooms.push(room); } // If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point. - await mjolnir.syncLists(); + await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); await Promise.all(protectedRooms.map(async room => { // We're going to need timeline pagination I'm afraid. const roomAcl = await mjolnir.client.getRoomStateEvent(room, "m.room.server_acl", ""); @@ -269,7 +269,7 @@ describe('Test: ACL updates will batch when rules are added in succession.', fun } // We do this because it should force us to wait until all the ACL events have been applied. // Even if that does mean the last few events will not go through batching... - await mjolnir.syncLists(); + await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); // At this point we check that the state within Mjolnir is internally consistent, this is just because debugging the following // is a pita. @@ -300,7 +300,7 @@ describe('Test: unbaning entities via the PolicyList.', function() { it('Will remove rules that have legacy types', async function() { const mjolnir: Mjolnir = this.mjolnir! const serverName: string = new UserID(await mjolnir.client.getUserId()).domain - const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); + const moderator: MatrixClient = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); this.moderator = moderator; await moderator.joinRoom(mjolnir.managementRoomId); const mjolnirId = await mjolnir.client.getUserId(); @@ -309,10 +309,10 @@ describe('Test: unbaning entities via the PolicyList.', function() { const protectedRoom = await moderator.createRoom({ invite: [mjolnirId] }); await mjolnir.client.joinRoom(protectedRoom); await moderator.setUserPowerLevel(mjolnirId, protectedRoom, 100); - await mjolnir.addProtectedRoom(protectedRoom); + await mjolnir.protectedRoomsTracker.addProtectedRoom(protectedRoom); // If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point. - await mjolnir.syncLists(); + await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); // If this is not present, then it means the room isn't being protected, which is really bad. const roomAcl = await mjolnir.client.getRoomStateEvent(protectedRoom, "m.room.server_acl", ""); assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.'); @@ -358,7 +358,7 @@ describe('Test: unbaning entities via the PolicyList.', function() { } // Wait for mjolnir to sync protected rooms to update ACL. - await mjolnir.syncLists(); + await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); // Confirm that the server is unbanned. await banList.updateList(); assert.equal(banList.allRules.length, 0); @@ -382,12 +382,12 @@ describe('Test: should apply bans to the most recently active rooms first', func const room = await moderator.createRoom({ invite: [mjolnirId] }); await mjolnir.client.joinRoom(room); await moderator.setUserPowerLevel(mjolnirId, room, 100); - await mjolnir.addProtectedRoom(room); + await mjolnir.protectedRoomsTracker.addProtectedRoom(room); protectedRooms.push(room); } // If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point. - await mjolnir.syncLists(); + await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); await Promise.all(protectedRooms.map(async room => { const roomAcl = await mjolnir.client.getRoomStateEvent(room, "m.room.server_acl", "").catch(e => e.statusCode === 404 ? { deny: [] } : Promise.reject(e)); assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.'); @@ -398,7 +398,7 @@ describe('Test: should apply bans to the most recently active rooms first', func await mjolnir.client.joinRoom(banListId); await mjolnir.watchList(Permalinks.forRoom(banListId)); - await mjolnir.syncLists(); + await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); // shuffle protected rooms https://stackoverflow.com/a/12646864, we do this so we can create activity "randomly" in them. for (let i = protectedRooms.length - 1; i > 0; i--) { @@ -413,7 +413,7 @@ describe('Test: should apply bans to the most recently active rooms first', func // check the rooms are in the expected order for (let i = 0; i < protectedRooms.length; i++) { - assert.equal(mjolnir.protectedRoomsByActivity()[i], protectedRooms[i]); + assert.equal(mjolnir.protectedRoomsTracker.protectedRoomsByActivity()[i], protectedRooms[i]); } const badServer = `evil.com`; @@ -422,10 +422,10 @@ describe('Test: should apply bans to the most recently active rooms first', func await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule ${badServer}`); // Wait until all the ACL events have been applied. - await mjolnir.syncLists(); + await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging); for (let i = 0; i < protectedRooms.length; i++) { - assert.equal(mjolnir.protectedRoomsByActivity()[i], protectedRooms.at(-i - 1)); + assert.equal(mjolnir.protectedRoomsTracker.protectedRoomsByActivity()[i], protectedRooms.at(-i - 1)); } // Check that the most recently active rooms got the ACL update first.