mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-10-01 01:36:06 -04:00
Refactor protected rooms. (#371)
* Attempt to factor out protected rooms from Mjolnir. This is useful to the appservice because it means we don't have to wrap a Mjolnir that is designed to sync. It's also useful if we later on want to have specific settings per space. It's also just a nice seperation between Mjolnir's needs while syncing via client-server and the behaviour of syncing policy rooms. ### Things that have changed - `ErrorCache` no longer a static class (phew), gets used by `ProtectedRooms`. - `ManagementRoomOutput` class gets created to handle logging back to the management room. - Responsibilities for syncing member bans and server ACL are handled by `ProtectedRooms`. - Responsibilities for watched lists should be moved to `ProtectedRooms` if they haven't been. - `EventRedactionQueue` is moved to `ProtectedRooms` since this needs to happen after member bans. - ApplyServerAcls moved to `ProtectedRooms` - ApplyMemberBans move to `ProtectedRooms` - `logMessage` and `replaceRoomIdsWithPills` moved to `ManagementRoomOutput`. - `resyncJoinedRooms` has been made a little more clear, though I am concerned about how often it does run because it does seem expensive. * ProtectedRooms is not supposed to track joined rooms. The reason is because it is supposed to represent a specific set of rooms to protect, not do horrible logic for working out what rooms mjolnir is supposed to protect.
This commit is contained in:
parent
f108935d07
commit
77ad40e27a
@ -22,35 +22,54 @@ const TRIGGER_INTERVALS: { [key: string]: number } = {
|
||||
[ERROR_KIND_FATAL]: 15 * 60 * 1000, // 15 minutes
|
||||
};
|
||||
|
||||
/**
|
||||
* The ErrorCache is used to suppress the same error messages for the same error state.
|
||||
* An example would be when mjolnir has been told to protect a room but is missing some permission such as the ability to send `m.room.server_acl`.
|
||||
* Each time `Mjolnir` synchronizes policies to protected rooms Mjolnir will try to log to the management room that Mjolnir doesn't have permission to send `m.room.server_acl`.
|
||||
* The ErrorCache is an attempt to make sure the error is reported only once.
|
||||
*/
|
||||
export default class ErrorCache {
|
||||
private static roomsToErrors: { [roomId: string]: { [kind: string]: number } } = {};
|
||||
private roomsToErrors: Map<string/*room id*/, Map<string /*error kind*/, number>> = new Map();
|
||||
|
||||
private constructor() {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
public static resetError(roomId: string, kind: string) {
|
||||
if (!ErrorCache.roomsToErrors[roomId]) {
|
||||
ErrorCache.roomsToErrors[roomId] = {};
|
||||
/**
|
||||
* Reset the error cache for a room/kind in the situation where circumstances have changed e.g. if Mjolnir has been informed via sync of a `m.room.power_levels` event in the room, we would want to clear `ERROR_KIND_PERMISSION`
|
||||
* so that a user can see if their changes worked.
|
||||
* @param roomId The room to reset the error cache for.
|
||||
* @param kind The kind of error we are resetting.
|
||||
*/
|
||||
public resetError(roomId: string, kind: string) {
|
||||
if (!this.roomsToErrors.has(roomId)) {
|
||||
this.roomsToErrors.set(roomId, new Map());
|
||||
}
|
||||
ErrorCache.roomsToErrors[roomId][kind] = 0;
|
||||
this.roomsToErrors.get(roomId)?.set(kind, 0);
|
||||
}
|
||||
|
||||
public static triggerError(roomId: string, kind: string): boolean {
|
||||
if (!ErrorCache.roomsToErrors[roomId]) {
|
||||
ErrorCache.roomsToErrors[roomId] = {};
|
||||
/**
|
||||
* Register the error with the cache.
|
||||
* @param roomId The room where the error is occuring or related to.
|
||||
* @param kind What kind of error, either `ERROR_KIND_PERMISSION` or `ERROR_KIND_FATAL`.
|
||||
* @returns True if the error kind has been triggered in that room,
|
||||
* meaning it has been longer than the time specified in `TRIGGER_INTERVALS` since the last trigger (or the first trigger). Otherwise false.
|
||||
*/
|
||||
public triggerError(roomId: string, kind: string): boolean {
|
||||
if (!this.roomsToErrors.get(roomId)) {
|
||||
this.roomsToErrors.set(roomId, new Map());
|
||||
}
|
||||
|
||||
const triggers = ErrorCache.roomsToErrors[roomId];
|
||||
if (!triggers[kind]) {
|
||||
triggers[kind] = 0;
|
||||
const triggers = this.roomsToErrors.get(roomId)!;
|
||||
if (!triggers.has(kind)) {
|
||||
triggers?.set(kind, 0);
|
||||
}
|
||||
|
||||
const lastTriggerTime = triggers[kind];
|
||||
const lastTriggerTime = triggers.get(kind)!;
|
||||
const now = new Date().getTime();
|
||||
const interval = TRIGGER_INTERVALS[kind];
|
||||
|
||||
if ((now - lastTriggerTime) >= interval) {
|
||||
triggers[kind] = now;
|
||||
triggers.set(kind, now);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
|
123
src/ManagementRoomOutput.ts
Normal file
123
src/ManagementRoomOutput.ts
Normal file
@ -0,0 +1,123 @@
|
||||
/*
|
||||
Copyright 2019, 2022 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 { extractRequestError, LogLevel, LogService, MatrixClient, MessageType, Permalinks, TextualMessageEventContent, UserID } from "matrix-bot-sdk";
|
||||
import { IConfig } from "./config";
|
||||
import { htmlEscape } from "./utils";
|
||||
|
||||
const levelToFn = {
|
||||
[LogLevel.DEBUG.toString()]: LogService.debug,
|
||||
[LogLevel.INFO.toString()]: LogService.info,
|
||||
[LogLevel.WARN.toString()]: LogService.warn,
|
||||
[LogLevel.ERROR.toString()]: LogService.error,
|
||||
};
|
||||
|
||||
/**
|
||||
* Allows the different componenets of mjolnir to send messages back to the management room without introducing a dependency on the entirity of a `Mjolnir` instance.
|
||||
*/
|
||||
export default class ManagementRoomOutput {
|
||||
|
||||
constructor(
|
||||
private readonly managementRoomId: string,
|
||||
private readonly client: MatrixClient,
|
||||
private readonly config: IConfig,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Take an arbitrary string and a set of room IDs, and return a
|
||||
* TextualMessageEventContent whose plaintext component replaces those room
|
||||
* IDs with their canonical aliases, and whose html component replaces those
|
||||
* room IDs with their matrix.to room pills.
|
||||
*
|
||||
* @param client The matrix client on which to query for room aliases
|
||||
* @param text An arbitrary string to rewrite with room aliases and pills
|
||||
* @param roomIds A set of room IDs to find and replace in `text`
|
||||
* @param msgtype The desired message type of the returned TextualMessageEventContent
|
||||
* @returns A TextualMessageEventContent with replaced room IDs
|
||||
*/
|
||||
private async replaceRoomIdsWithPills(text: string, roomIds: Set<string>, msgtype: MessageType = "m.text"): Promise<TextualMessageEventContent> {
|
||||
const content: TextualMessageEventContent = {
|
||||
body: text,
|
||||
formatted_body: htmlEscape(text),
|
||||
msgtype: msgtype,
|
||||
format: "org.matrix.custom.html",
|
||||
};
|
||||
|
||||
// Though spec doesn't say so, room ids that have slashes in them are accepted by Synapse and Dendrite unfortunately for us.
|
||||
const escapeRegex = (v: string): string => {
|
||||
return v.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
};
|
||||
|
||||
const viaServers = [(new UserID(await this.client.getUserId())).domain];
|
||||
for (const roomId of roomIds) {
|
||||
let alias = roomId;
|
||||
try {
|
||||
alias = (await this.client.getPublishedAlias(roomId)) || roomId;
|
||||
} catch (e) {
|
||||
// This is a recursive call, so tell the function not to try and call us
|
||||
await this.logMessage(LogLevel.WARN, "utils", `Failed to resolve room alias for ${roomId} - see console for details`, null, true);
|
||||
LogService.warn("utils", extractRequestError(e));
|
||||
}
|
||||
const regexRoomId = new RegExp(escapeRegex(roomId), "g");
|
||||
content.body = content.body.replace(regexRoomId, alias);
|
||||
if (content.formatted_body) {
|
||||
const permalink = Permalinks.forRoom(alias, alias !== roomId ? [] : viaServers);
|
||||
content.formatted_body = content.formatted_body.replace(regexRoomId, `<a href="${permalink}">${htmlEscape(alias)}</a>`);
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message to the management room and the console, replaces any room ids in additionalRoomIds with pills.
|
||||
*
|
||||
* @param level Used to determine whether to hide the message or not depending on `config.verboseLogging`.
|
||||
* @param module Used to help find where in the source the message is coming from (when logging to the console).
|
||||
* @param message The message we want to log.
|
||||
* @param additionalRoomIds The roomIds in the message that we want to be replaced by room pills.
|
||||
* @param isRecursive Whether logMessage is being called from logMessage.
|
||||
*/
|
||||
public async logMessage(level: LogLevel, module: string, message: string | any, additionalRoomIds: string[] | string | null = null, isRecursive = false): Promise<any> {
|
||||
if (!additionalRoomIds) additionalRoomIds = [];
|
||||
if (!Array.isArray(additionalRoomIds)) additionalRoomIds = [additionalRoomIds];
|
||||
|
||||
if (this.config.verboseLogging || LogLevel.INFO.includes(level)) {
|
||||
let clientMessage = message;
|
||||
if (level === LogLevel.WARN) clientMessage = `⚠ | ${message}`;
|
||||
if (level === LogLevel.ERROR) clientMessage = `‼ | ${message}`;
|
||||
|
||||
const client = this.client;
|
||||
const roomIds = [this.managementRoomId, ...additionalRoomIds];
|
||||
|
||||
let evContent: TextualMessageEventContent = {
|
||||
body: message,
|
||||
formatted_body: htmlEscape(message),
|
||||
msgtype: "m.notice",
|
||||
format: "org.matrix.custom.html",
|
||||
};
|
||||
if (!isRecursive) {
|
||||
evContent = await this.replaceRoomIdsWithPills(clientMessage, new Set(roomIds), "m.notice");
|
||||
}
|
||||
|
||||
await client.sendMessage(this.managementRoomId, evContent);
|
||||
}
|
||||
|
||||
levelToFn[level.toString()](module, message);
|
||||
}
|
||||
}
|
727
src/Mjolnir.ts
727
src/Mjolnir.ts
@ -20,54 +20,35 @@ 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 { 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 { replaceRoomIdsWithPills } from "./utils";
|
||||
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";
|
||||
|
||||
const levelToFn = {
|
||||
[LogLevel.DEBUG.toString()]: LogService.debug,
|
||||
[LogLevel.INFO.toString()]: LogService.info,
|
||||
[LogLevel.WARN.toString()]: LogService.warn,
|
||||
[LogLevel.ERROR.toString()]: LogService.error,
|
||||
};
|
||||
import PolicyList from "./models/PolicyList";
|
||||
import { ProtectedRooms } from "./ProtectedRooms";
|
||||
import ManagementRoomOutput from "./ManagementRoomOutput";
|
||||
import { ProtectionManager } from "./protections/ProtectionManager";
|
||||
import { RoomMemberManager } from "./RoomMembers";
|
||||
|
||||
export const STATE_NOT_STARTED = "not_started";
|
||||
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
|
||||
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 WATCHED_LISTS_EVENT_TYPE = "org.matrix.mjolnir.watched_lists";
|
||||
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
|
||||
@ -79,18 +60,11 @@ export class Mjolnir {
|
||||
private localpart: string;
|
||||
private currentState: string = STATE_NOT_STARTED;
|
||||
public readonly roomJoins: RoomMemberManager;
|
||||
public protections = new Map<string /* protection name */, Protection>();
|
||||
/**
|
||||
* This is for users who are not listed on a watchlist,
|
||||
* but have been flagged by the automatic spam detection as suispicous
|
||||
*/
|
||||
private unlistedUserRedactionQueue = new UnlistedUserRedactionQueue();
|
||||
/**
|
||||
* This is a queue for redactions to process after mjolnir
|
||||
* has finished applying ACL and bans when syncing.
|
||||
*/
|
||||
private eventRedactionQueue = new EventRedactionQueue();
|
||||
private automaticRedactionReasons: MatrixGlob[] = [];
|
||||
/**
|
||||
* Every room that we are joined to except the management room. Used to implement `config.protectAllJoinedRooms`.
|
||||
*/
|
||||
@ -99,23 +73,31 @@ export class Mjolnir {
|
||||
* 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[] = [];
|
||||
/**
|
||||
* These are rooms that we have joined to watch the list, but don't have permission to protect.
|
||||
* These are eventually are exluded from `protectedRooms` in `applyUnprotectedRooms` via `resyncJoinedRooms`.
|
||||
*/
|
||||
private unprotectedWatchedListRooms: string[] = [];
|
||||
public readonly protectedRoomsTracker: ProtectedRooms;
|
||||
private webapis: WebAPIs;
|
||||
private protectedRoomActivityTracker: ProtectedRoomActivityTracker;
|
||||
public taskQueue: ThrottlingQueue;
|
||||
/**
|
||||
* 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
|
||||
* could race & give the room an inconsistent state. An example is if we add multiple m.policy.rule.server rules,
|
||||
* which would cause several requests to a room to send a new m.room.server_acl event.
|
||||
* These requests could finish in any order, which has left rooms with an inconsistent server_acl event
|
||||
* until Mjolnir synchronises the room with its policy lists again, which can be in the region of hours.
|
||||
* Reporting back to the management room.
|
||||
*/
|
||||
public aclChain: Promise<void> = Promise.resolve();
|
||||
public readonly managementRoomOutput: ManagementRoomOutput;
|
||||
/*
|
||||
* Config-enabled polling of reports in Synapse, so Mjolnir can react to reports
|
||||
*/
|
||||
private reportPoller?: ReportPoller;
|
||||
/**
|
||||
* Store the protections being used by Mjolnir.
|
||||
*/
|
||||
public readonly protectionManager: ProtectionManager;
|
||||
/**
|
||||
* Handle user reports from the homeserver.
|
||||
*/
|
||||
public readonly reportManager: ReportManager;
|
||||
|
||||
/**
|
||||
* Adds a listener to the client that will automatically accept invitations.
|
||||
* @param {MatrixClient} client
|
||||
@ -151,7 +133,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.managementRoomOutput.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 {
|
||||
@ -196,14 +178,15 @@ 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.");
|
||||
const mjolnir = new Mjolnir(client, await client.getUserId(), managementRoomId, config, protectedRooms, policyLists, ruleServer);
|
||||
await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status.");
|
||||
Mjolnir.addJoinOnInviteListener(mjolnir, client, config);
|
||||
return mjolnir;
|
||||
}
|
||||
|
||||
constructor(
|
||||
public readonly client: MatrixClient,
|
||||
private readonly clientUserId: string,
|
||||
public readonly managementRoomId: string,
|
||||
public readonly config: IConfig,
|
||||
/*
|
||||
@ -217,10 +200,6 @@ export class Mjolnir {
|
||||
) {
|
||||
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));
|
||||
@ -278,20 +257,22 @@ export class Mjolnir {
|
||||
}
|
||||
});
|
||||
|
||||
// Setup room activity watcher
|
||||
this.protectedRoomActivityTracker = new ProtectedRoomActivityTracker(client);
|
||||
|
||||
// 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);
|
||||
|
||||
this.managementRoomOutput = new ManagementRoomOutput(managementRoomId, client, config);
|
||||
const protections = new ProtectionManager(this);
|
||||
this.protectedRoomsTracker = new ProtectedRooms(client, clientUserId, managementRoomId, this.managementRoomOutput, protections, config);
|
||||
}
|
||||
|
||||
public get lists(): PolicyList[] {
|
||||
@ -302,10 +283,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.
|
||||
@ -315,10 +292,6 @@ export class Mjolnir {
|
||||
return this.unlistedUserRedactionQueue;
|
||||
}
|
||||
|
||||
public get automaticRedactGlobs(): MatrixGlob[] {
|
||||
return this.automaticRedactionReasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Mjölnir.
|
||||
*/
|
||||
@ -339,7 +312,7 @@ export class Mjolnir {
|
||||
if (err.body?.errcode !== "M_NOT_FOUND") {
|
||||
throw err;
|
||||
} else {
|
||||
this.logMessage(LogLevel.INFO, "Mjolnir@startup", "report poll setting does not exist yet");
|
||||
this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "report poll setting does not exist yet");
|
||||
}
|
||||
}
|
||||
this.reportPoller.start(reportPollSetting.from);
|
||||
@ -348,7 +321,7 @@ export class Mjolnir {
|
||||
// Load the state.
|
||||
this.currentState = STATE_CHECKING_PERMISSIONS;
|
||||
|
||||
await this.logMessage(LogLevel.DEBUG, "Mjolnir@startup", "Loading protected rooms...");
|
||||
await this.managementRoomOutput.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);
|
||||
@ -356,7 +329,6 @@ export class Mjolnir {
|
||||
for (const roomId of data['rooms']) {
|
||||
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
|
||||
this.explicitlyProtectedRoomIds.push(roomId);
|
||||
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@ -364,27 +336,27 @@ export class Mjolnir {
|
||||
}
|
||||
await this.buildWatchedPolicyLists();
|
||||
this.applyUnprotectedRooms();
|
||||
await this.protectionManager.start();
|
||||
|
||||
if (this.config.verifyPermissionsOnStartup) {
|
||||
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions...");
|
||||
await this.verifyPermissions(this.config.verboseLogging);
|
||||
await this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions...");
|
||||
await this.protectedRoomsTracker.verifyPermissions(this.config.verboseLogging);
|
||||
}
|
||||
|
||||
this.currentState = STATE_SYNCING;
|
||||
if (this.config.syncOnStartup) {
|
||||
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists...");
|
||||
await this.syncLists(this.config.verboseLogging);
|
||||
await this.registerProtections();
|
||||
await this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists...");
|
||||
await this.protectedRoomsTracker.syncLists(this.config.verboseLogging);
|
||||
}
|
||||
|
||||
this.currentState = STATE_RUNNING;
|
||||
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms.");
|
||||
await this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms.");
|
||||
} catch (err) {
|
||||
try {
|
||||
LogService.error("Mjolnir", "Error during startup:");
|
||||
LogService.error("Mjolnir", extractRequestError(err));
|
||||
this.stop();
|
||||
await this.logMessage(LogLevel.ERROR, "Mjolnir@startup", "Startup failed due to error - see console");
|
||||
await this.managementRoomOutput.logMessage(LogLevel.ERROR, "Mjolnir@startup", "Startup failed due to error - see console");
|
||||
throw err;
|
||||
} catch (e) {
|
||||
LogService.error("Mjolnir", `Failed to report startup error to the management room: ${e}`);
|
||||
@ -403,39 +375,10 @@ export class Mjolnir {
|
||||
this.reportPoller?.stop();
|
||||
}
|
||||
|
||||
public async logMessage(level: LogLevel, module: string, message: string | any, additionalRoomIds: string[] | string | null = null, isRecursive = false): Promise<any> {
|
||||
if (!additionalRoomIds) additionalRoomIds = [];
|
||||
if (!Array.isArray(additionalRoomIds)) additionalRoomIds = [additionalRoomIds];
|
||||
|
||||
if (this.config.verboseLogging || LogLevel.INFO.includes(level)) {
|
||||
let clientMessage = message;
|
||||
if (level === LogLevel.WARN) clientMessage = `⚠ | ${message}`;
|
||||
if (level === LogLevel.ERROR) clientMessage = `‼ | ${message}`;
|
||||
|
||||
const client = this.client;
|
||||
const roomIds = [this.managementRoomId, ...additionalRoomIds];
|
||||
|
||||
let evContent: TextualMessageEventContent = {
|
||||
body: message,
|
||||
formatted_body: htmlEscape(message),
|
||||
msgtype: "m.notice",
|
||||
format: "org.matrix.custom.html",
|
||||
};
|
||||
if (!isRecursive) {
|
||||
evContent = await replaceRoomIdsWithPills(this, clientMessage, new Set(roomIds), "m.notice");
|
||||
}
|
||||
|
||||
await client.sendMessage(this.managementRoomId, evContent);
|
||||
}
|
||||
|
||||
levelToFn[level.toString()](module, message);
|
||||
}
|
||||
|
||||
|
||||
public async addProtectedRoom(roomId: string) {
|
||||
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
|
||||
this.roomJoins.addRoom(roomId);
|
||||
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
|
||||
this.protectedRoomsTracker.addProtectedRoom(roomId);
|
||||
|
||||
const unprotectedIdx = this.unprotectedWatchedListRooms.indexOf(roomId);
|
||||
if (unprotectedIdx >= 0) this.unprotectedWatchedListRooms.splice(unprotectedIdx, 1);
|
||||
@ -450,13 +393,12 @@ export class Mjolnir {
|
||||
const rooms = (additionalProtectedRooms?.rooms ?? []);
|
||||
rooms.push(roomId);
|
||||
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: rooms });
|
||||
await this.syncLists(this.config.verboseLogging);
|
||||
}
|
||||
|
||||
public async removeProtectedRoom(roomId: string) {
|
||||
delete this.protectedRooms[roomId];
|
||||
this.roomJoins.removeRoom(roomId);
|
||||
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
|
||||
this.protectedRoomsTracker.removeProtectedRoom(roomId);
|
||||
|
||||
const idx = this.explicitlyProtectedRoomIds.indexOf(roomId);
|
||||
if (idx >= 0) this.explicitlyProtectedRoomIds.splice(idx, 1);
|
||||
@ -471,194 +413,40 @@ export class Mjolnir {
|
||||
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms);
|
||||
}
|
||||
|
||||
// See https://github.com/matrix-org/mjolnir/issues/370.
|
||||
private async resyncJoinedRooms(withSync = true) {
|
||||
if (!this.config.protectAllJoinedRooms) return;
|
||||
|
||||
const joinedRoomIds = (await this.client.getJoinedRooms()).filter(r => r !== this.managementRoomId);
|
||||
const joinedRoomIds = (await this.client.getJoinedRooms())
|
||||
.filter(r => r !== this.managementRoomId && !this.unprotectedWatchedListRooms.includes(r));
|
||||
const oldRoomIdsSet = new Set(this.protectedJoinedRoomIds);
|
||||
const joinedRoomIdsSet = new Set(joinedRoomIds);
|
||||
// Remove every room id that we have joined from `this.protectedRooms`.
|
||||
for (const roomId of this.protectedJoinedRoomIds) {
|
||||
delete this.protectedRooms[roomId];
|
||||
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
|
||||
// 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.roomJoins.removeRoom(roomId);
|
||||
}
|
||||
}
|
||||
this.protectedJoinedRoomIds = joinedRoomIds;
|
||||
// Add all joined rooms back to the permalink object
|
||||
for (const roomId of joinedRoomIds) {
|
||||
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
|
||||
this.protectedRoomActivityTracker.addProtectedRoom(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);
|
||||
}
|
||||
}
|
||||
// update our internal representation of joined rooms.
|
||||
this.protectedJoinedRoomIds = joinedRoomIds;
|
||||
|
||||
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.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<any> {
|
||||
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.
|
||||
@ -668,22 +456,13 @@ export class Mjolnir {
|
||||
private async addPolicyList(roomId: string, roomRef: string): Promise<PolicyList> {
|
||||
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);
|
||||
this.protectedRoomsTracker.watchList(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<PolicyList | null> {
|
||||
const joinedRooms = await this.client.getJoinedRooms();
|
||||
const permalink = Permalinks.parseUrl(roomRef);
|
||||
@ -716,6 +495,7 @@ export class Mjolnir {
|
||||
if (list) {
|
||||
this.policyLists.splice(this.policyLists.indexOf(list), 1);
|
||||
this.ruleServer?.unwatch(list);
|
||||
this.protectedRoomsTracker.unwatchList(list);
|
||||
}
|
||||
|
||||
await this.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, {
|
||||
@ -741,14 +521,18 @@ export class Mjolnir {
|
||||
// Ignore - probably haven't warned about it yet
|
||||
}
|
||||
|
||||
await this.logMessage(LogLevel.WARN, "Mjolnir", `Not protecting ${roomId} - it is a ban list that this bot did not create. Add the room as protected if it is supposed to be protected. This warning will not appear again.`, roomId);
|
||||
await this.managementRoomOutput.logMessage(LogLevel.WARN, "Mjolnir", `Not protecting ${roomId} - it is a ban list that this bot did not create. Add the room as protected if it is supposed to be protected. This warning will not appear again.`, roomId);
|
||||
await this.client.setAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId, { warned: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* So this is called to retroactively remove protected rooms from Mjolnir's internal model of joined rooms.
|
||||
* This is really shit and needs to be changed asap. Unacceptable even.
|
||||
*/
|
||||
private applyUnprotectedRooms() {
|
||||
for (const roomId of this.unprotectedWatchedListRooms) {
|
||||
delete this.protectedRooms[roomId];
|
||||
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
|
||||
this.protectedRoomsTracker.removeProtectedRoom(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -777,221 +561,6 @@ export class Mjolnir {
|
||||
}
|
||||
}
|
||||
|
||||
public async verifyPermissions(verbose = true, printRegardless = false) {
|
||||
const errors: RoomUpdateError[] = [];
|
||||
for (const roomId of Object.keys(this.protectedRooms)) {
|
||||
errors.push(...(await this.verifyPermissionsIn(roomId)));
|
||||
}
|
||||
|
||||
const hadErrors = await this.printActionResult(errors, "Permission errors in protected rooms:", printRegardless);
|
||||
if (!hadErrors && verbose) {
|
||||
const html = `<font color="#00cc00">All permissions look OK.</font>`;
|
||||
const text = "All permissions look OK.";
|
||||
await this.client.sendMessage(this.managementRoomId, {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyPermissionsIn(roomId: string): Promise<RoomUpdateError[]> {
|
||||
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 : '<no message>'),
|
||||
errorKind: ERROR_KIND_FATAL,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private requiredProtectionPermissions(): Set<string> {
|
||||
return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat())
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The protected rooms ordered by the most recently active first.
|
||||
*/
|
||||
public protectedRoomsByActivity(): string[] {
|
||||
return this.protectedRoomActivityTracker.protectedRoomsByActivity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync all the rooms with all the watched lists, banning and applying any changed ACLS.
|
||||
* @param verbose Whether to report any errors to the management room.
|
||||
*/
|
||||
public async syncLists(verbose = true) {
|
||||
for (const list of this.policyLists) {
|
||||
const changes = await list.updateList();
|
||||
await this.printBanlistChanges(changes, list, true);
|
||||
}
|
||||
|
||||
let hadErrors = false;
|
||||
const [aclErrors, banErrors] = await Promise.all([
|
||||
applyServerAcls(this.policyLists, this.protectedRoomsByActivity(), this),
|
||||
applyUserBans(this.policyLists, this.protectedRoomsByActivity(), this)
|
||||
]);
|
||||
const redactionErrors = await this.processRedactionQueue();
|
||||
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
|
||||
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
|
||||
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
|
||||
|
||||
if (!hadErrors && verbose) {
|
||||
const html = `<font color="#00cc00">Done updating rooms - no errors</font>`;
|
||||
const text = "Done updating rooms - no errors";
|
||||
await this.client.sendMessage(this.managementRoomId, {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls any changes to the rules that are in a policy room and updates all protected rooms
|
||||
* with those changes. Does not fail if there are errors updating the room, these are reported to the management room.
|
||||
* @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<void> {
|
||||
const changes = await policyList.updateList();
|
||||
|
||||
let hadErrors = false;
|
||||
const [aclErrors, banErrors] = await Promise.all([
|
||||
applyServerAcls(this.policyLists, this.protectedRoomsByActivity(), this),
|
||||
applyUserBans(this.policyLists, this.protectedRoomsByActivity(), this)
|
||||
]);
|
||||
const redactionErrors = await this.processRedactionQueue();
|
||||
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
|
||||
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
|
||||
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
|
||||
|
||||
if (!hadErrors) {
|
||||
const html = `<font color="#00cc00"><b>Done updating rooms - no errors</b></font>`;
|
||||
const text = "Done updating rooms - no errors";
|
||||
await this.client.sendMessage(this.managementRoomId, {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
});
|
||||
}
|
||||
// This can fail if the change is very large and it is much less important than applying bans, so do it last.
|
||||
await this.printBanlistChanges(changes, policyList, true);
|
||||
}
|
||||
|
||||
private async handleConsequences(protection: Protection, roomId: string, eventId: string, sender: string, consequences: Consequence[]) {
|
||||
for (const consequence of consequences) {
|
||||
try {
|
||||
if (consequence.name === "alert") {
|
||||
/* take no additional action, just print the below message to management room */
|
||||
} else if (consequence.name === "ban") {
|
||||
await this.client.banUser(sender, roomId, "abuse detected");
|
||||
} else if (consequence.name === "redact") {
|
||||
await this.client.redactEvent(roomId, eventId, "abuse detected");
|
||||
} else {
|
||||
throw new Error(`unknown consequence ${consequence.name}`);
|
||||
}
|
||||
|
||||
let message = `protection ${protection.name} enacting`
|
||||
+ ` ${consequence.name}`
|
||||
+ ` against ${htmlEscape(sender)}`
|
||||
+ ` in ${htmlEscape(roomId)}`
|
||||
+ ` (reason: ${htmlEscape(consequence.reason)})`;
|
||||
await this.client.sendMessage(this.managementRoomId, {
|
||||
msgtype: "m.notice",
|
||||
body: message,
|
||||
[CONSEQUENCE_EVENT_DATA]: {
|
||||
who: sender,
|
||||
room: roomId,
|
||||
types: [consequence.name],
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
await this.logMessage(LogLevel.ERROR, "handleConsequences", `Failed to enact ${consequence.name} consequence: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleEvent(roomId: string, event: any) {
|
||||
// Check for UISI errors
|
||||
if (roomId === this.managementRoomId) {
|
||||
@ -1014,139 +583,11 @@ export class Mjolnir {
|
||||
}
|
||||
}
|
||||
|
||||
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 consequences: Consequence[] | undefined = undefined;
|
||||
try {
|
||||
consequences = 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 (consequences !== undefined) {
|
||||
await this.handleConsequences(protection, roomId, event["event_id"], event["sender"], consequences);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
if (event['type'] === 'm.room.power_levels' && event['state_key'] === '') {
|
||||
// power levels were updated - recheck permissions
|
||||
ErrorCache.resetError(roomId, ERROR_KIND_PERMISSION);
|
||||
await this.logMessage(LogLevel.DEBUG, "Mjolnir", `Power levels changed in ${roomId} - checking permissions...`, roomId);
|
||||
const errors = await this.verifyPermissionsIn(roomId);
|
||||
const hadErrors = await this.printActionResult(errors);
|
||||
if (!hadErrors) {
|
||||
await this.logMessage(LogLevel.DEBUG, "Mjolnir", `All permissions look OK.`);
|
||||
}
|
||||
return;
|
||||
} else if (event['type'] === "m.room.member") {
|
||||
// The reason we have to apply bans on each member change is because
|
||||
// we cannot eagerly ban users (that is to ban them when they have never been a member)
|
||||
// as they can be force joined to a room they might not have known existed.
|
||||
// Only apply bans and then redactions in the room we are currently looking at.
|
||||
const banErrors = await applyUserBans(this.policyLists, [roomId], this);
|
||||
const redactionErrors = await this.processRedactionQueue(roomId);
|
||||
await this.printActionResult(banErrors);
|
||||
await this.printActionResult(redactionErrors);
|
||||
}
|
||||
if (event.sender !== this.clientUserId) {
|
||||
this.protectedRoomsTracker.handleEvent(roomId, event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the changes to a banlist to the management room.
|
||||
* @param changes A list of changes that have been made to a particular ban list.
|
||||
* @param ignoreSelf Whether to exclude changes that have been made by Mjolnir.
|
||||
* @returns true if the message was sent, false if it wasn't (because there there were no changes to report).
|
||||
*/
|
||||
private async printBanlistChanges(changes: ListRuleChange[], list: PolicyList, ignoreSelf = false): Promise<boolean> {
|
||||
if (ignoreSelf) {
|
||||
const sender = await this.client.getUserId();
|
||||
changes = changes.filter(change => change.sender !== sender);
|
||||
}
|
||||
if (changes.length <= 0) return false;
|
||||
|
||||
let html = "";
|
||||
let text = "";
|
||||
|
||||
const changesInfo = `updated with ${changes.length} ` + (changes.length === 1 ? 'change:' : 'changes:');
|
||||
const shortcodeInfo = list.listShortcode ? ` (shortcode: ${htmlEscape(list.listShortcode)})` : '';
|
||||
|
||||
html += `<a href="${htmlEscape(list.roomRef)}">${htmlEscape(list.roomId)}</a>${shortcodeInfo} ${changesInfo}<br/><ul>`;
|
||||
text += `${list.roomRef}${shortcodeInfo} ${changesInfo}:\n`;
|
||||
|
||||
for (const change of changes) {
|
||||
const rule = change.rule;
|
||||
let ruleKind: string = rule.kind;
|
||||
if (ruleKind === RULE_USER) {
|
||||
ruleKind = 'user';
|
||||
} else if (ruleKind === RULE_SERVER) {
|
||||
ruleKind = 'server';
|
||||
} else if (ruleKind === RULE_ROOM) {
|
||||
ruleKind = 'room';
|
||||
}
|
||||
html += `<li>${change.changeType} ${htmlEscape(ruleKind)} (<code>${htmlEscape(rule.recommendation ?? "")}</code>): <code>${htmlEscape(rule.entity)}</code> (${htmlEscape(rule.reason)})</li>`;
|
||||
text += `* ${change.changeType} ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`;
|
||||
}
|
||||
|
||||
const message = {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
};
|
||||
await this.client.sendMessage(this.managementRoomId, message);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async printActionResult(errors: RoomUpdateError[], title: string | null = null, logAnyways = false) {
|
||||
if (errors.length <= 0) return false;
|
||||
|
||||
if (!logAnyways) {
|
||||
errors = errors.filter(e => ErrorCache.triggerError(e.roomId, e.errorKind));
|
||||
if (errors.length <= 0) {
|
||||
LogService.warn("Mjolnir", "Multiple errors are happening, however they are muted. Please check the management room.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let html = "";
|
||||
let text = "";
|
||||
|
||||
const htmlTitle = title ? `${title}<br />` : '';
|
||||
const textTitle = title ? `${title}\n` : '';
|
||||
|
||||
html += `<font color="#ff0000"><b>${htmlTitle}${errors.length} errors updating protected rooms!</b></font><br /><ul>`;
|
||||
text += `${textTitle}${errors.length} errors updating protected rooms!\n`;
|
||||
const viaServers = [(new UserID(await this.client.getUserId())).domain];
|
||||
for (const error of errors) {
|
||||
const alias = (await this.client.getPublishedAlias(error.roomId)) || error.roomId;
|
||||
const url = Permalinks.forRoom(alias, viaServers);
|
||||
html += `<li><a href="${url}">${alias}</a> - ${error.errorMessage}</li>`;
|
||||
text += `${url} - ${error.errorMessage}\n`;
|
||||
}
|
||||
html += "</ul>";
|
||||
|
||||
const message = {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
};
|
||||
await this.client.sendMessage(this.managementRoomId, message);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async isSynapseAdmin(): Promise<boolean> {
|
||||
try {
|
||||
const endpoint = `/_synapse/admin/v1/users/${await this.client.getUserId()}/admin`;
|
||||
@ -1189,26 +630,4 @@ export class Mjolnir {
|
||||
return extractRequestError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public queueRedactUserMessagesIn(userId: string, roomId: string) {
|
||||
this.eventRedactionQueue.add(new RedactUserInRoom(userId, roomId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all queued redactions, this is usually called at the end of the sync process,
|
||||
* after all users have been banned and ACLs applied.
|
||||
* If a redaction cannot be processed, the redaction is skipped and removed from the queue.
|
||||
* We then carry on processing the next redactions.
|
||||
* @param roomId Limit processing to one room only, otherwise process redactions for all rooms.
|
||||
* @returns The list of errors encountered, for reporting to the management room.
|
||||
*/
|
||||
public async processRedactionQueue(roomId?: string): Promise<RoomUpdateError[]> {
|
||||
return await this.eventRedactionQueue.process(this, roomId);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
524
src/ProtectedRooms.ts
Normal file
524
src/ProtectedRooms.ts
Normal file
@ -0,0 +1,524 @@
|
||||
/*
|
||||
Copyright 2019, 2022 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 { LogLevel, LogService, MatrixClient, MatrixGlob, Permalinks, UserID } from "matrix-bot-sdk";
|
||||
import { IConfig } from "./config";
|
||||
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
|
||||
import ManagementRoomOutput from "./ManagementRoomOutput";
|
||||
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/ProtectionManager";
|
||||
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
|
||||
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
|
||||
import { htmlEscape } from "./utils";
|
||||
|
||||
/**
|
||||
* This class aims to synchronize `m.ban` rules in a set of policy lists with
|
||||
* a set of rooms by applying member bans and server ACL to them.
|
||||
*
|
||||
* It is important to understand that the use of `m.ban` in the lists that `ProtectedRooms` watch
|
||||
* are interpreted to be the final decision about whether to ban a user and are a synchronization tool.
|
||||
* This is different to watching a community curated list to be informed about reputation information and then making
|
||||
* some sort of decision and is not the purpose of this class (as of writing, Mjolnir does not have a way to do this, we want it to).
|
||||
* The outcome of that decision process (which should take place in other components)
|
||||
* will likely be whether or not to create an `m.ban` rule in a list watched by
|
||||
* your protected rooms.
|
||||
*
|
||||
* It is also important not to tie this to the one group of rooms that a mjolnir may watch
|
||||
* as in future we might want to borrow this class to represent a space https://github.com/matrix-org/mjolnir/issues/283.
|
||||
*/
|
||||
export class ProtectedRooms {
|
||||
|
||||
private protectedRooms = new Set</* room id */string>();
|
||||
|
||||
/**
|
||||
* These are the `m.bans` we want to synchronize across this set of rooms.
|
||||
*/
|
||||
private policyLists: PolicyList[] = [];
|
||||
|
||||
/**
|
||||
* Tracks the rooms so that the most recently active rooms can be synchronized first.
|
||||
*/
|
||||
private protectedRoomActivityTracker: ProtectedRoomActivityTracker;
|
||||
|
||||
/**
|
||||
* This is a queue for redactions to process after mjolnir
|
||||
* has finished applying ACL and bans when syncing.
|
||||
*/
|
||||
private readonly eventRedactionQueue = new EventRedactionQueue();
|
||||
|
||||
private readonly errorCache = new ErrorCache();
|
||||
|
||||
/**
|
||||
* These are globs sourced from `config.automaticallyRedactForReasons` that are matched against the reason of an
|
||||
* `m.ban` recommendation against a user.
|
||||
* If a rule matches a user in a room, and a glob from here matches that rule's reason, then we will redact
|
||||
* all of the messages from that user.
|
||||
*/
|
||||
private automaticRedactionReasons: MatrixGlob[] = [];
|
||||
|
||||
/**
|
||||
* 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
|
||||
* could race & give the room an inconsistent state. An example is if we add multiple m.policy.rule.server rules,
|
||||
* which would cause several requests to a room to send a new m.room.server_acl event.
|
||||
* These requests could finish in any order, which has left rooms with an inconsistent server_acl event
|
||||
* until Mjolnir synchronises the room with its policy lists again, which can be in the region of hours.
|
||||
*/
|
||||
private aclChain: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly client: MatrixClient,
|
||||
private readonly clientUserId: string,
|
||||
private readonly managementRoomId: string,
|
||||
private readonly managementRoomOutput: ManagementRoomOutput,
|
||||
/**
|
||||
* The protection manager is only used to verify the permissions
|
||||
* that the protection manager requires are correct for this set of rooms.
|
||||
* The protection manager is not really compatible with this abstraction yet
|
||||
* because of a direct dependency on the protection manager in Mjolnir commands.
|
||||
*/
|
||||
private readonly protectionManager: ProtectionManager,
|
||||
private readonly config: IConfig,
|
||||
) {
|
||||
for (const reason of this.config.automaticallyRedactForReasons) {
|
||||
this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase()));
|
||||
}
|
||||
|
||||
// Setup room activity watcher
|
||||
this.protectedRoomActivityTracker = new ProtectedRoomActivityTracker();
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a user's messages in a room for redaction once we have stopped synchronizing bans
|
||||
* over the protected rooms.
|
||||
*
|
||||
* @param userId The user whose messages we want to redact.
|
||||
* @param roomId The room we want to redact them in.
|
||||
*/
|
||||
public redactUser(userId: string, roomId: string) {
|
||||
this.eventRedactionQueue.add(new RedactUserInRoom(userId, roomId));
|
||||
}
|
||||
|
||||
/**
|
||||
* These are globs sourced from `config.automaticallyRedactForReasons` that are matched against the reason of an
|
||||
* `m.ban` recommendation against a user.
|
||||
* If a rule matches a user in a room, and a glob from here matches that rule's reason, then we will redact
|
||||
* all of the messages from that user.
|
||||
*/
|
||||
public get automaticRedactGlobs(): Readonly<MatrixGlob[]> {
|
||||
return this.automaticRedactionReasons;
|
||||
}
|
||||
|
||||
public getProtectedRooms () {
|
||||
return [...this.protectedRooms.keys()]
|
||||
}
|
||||
|
||||
public isProtectedRoom(roomId: string): boolean {
|
||||
return this.protectedRooms.has(roomId);
|
||||
}
|
||||
|
||||
public watchList(policyList: PolicyList): void {
|
||||
if (!this.policyLists.includes(policyList)) {
|
||||
this.policyLists.push(policyList);
|
||||
}
|
||||
}
|
||||
|
||||
public unwatchList(policyList: PolicyList): void {
|
||||
this.policyLists = this.policyLists.filter(list => list.roomId !== policyList.roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all queued redactions, this is usually called at the end of the sync process,
|
||||
* after all users have been banned and ACLs applied.
|
||||
* If a redaction cannot be processed, the redaction is skipped and removed from the queue.
|
||||
* We then carry on processing the next redactions.
|
||||
* @param roomId Limit processing to one room only, otherwise process redactions for all rooms.
|
||||
* @returns The list of errors encountered, for reporting to the management room.
|
||||
*/
|
||||
public async processRedactionQueue(roomId?: string): Promise<RoomUpdateError[]> {
|
||||
return await this.eventRedactionQueue.process(this.client, this.managementRoomOutput, roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The protected rooms ordered by the most recently active first.
|
||||
*/
|
||||
public protectedRoomsByActivity(): string[] {
|
||||
return this.protectedRoomActivityTracker.protectedRoomsByActivity();
|
||||
}
|
||||
|
||||
public async handleEvent(roomId: string, event: any) {
|
||||
if (event['sender'] === this.clientUserId) {
|
||||
throw new TypeError("`ProtectedRooms::handleEvent` should not be used to inform about events sent by mjolnir.");
|
||||
}
|
||||
this.protectedRoomActivityTracker.handleEvent(roomId, event);
|
||||
if (event['type'] === 'm.room.power_levels' && event['state_key'] === '') {
|
||||
// power levels were updated - recheck permissions
|
||||
this.errorCache.resetError(roomId, ERROR_KIND_PERMISSION);
|
||||
await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "Mjolnir", `Power levels changed in ${roomId} - checking permissions...`, roomId);
|
||||
const errors = await this.protectionManager.verifyPermissionsIn(roomId);
|
||||
const hadErrors = await this.printActionResult(errors);
|
||||
if (!hadErrors) {
|
||||
await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "Mjolnir", `All permissions look OK.`);
|
||||
}
|
||||
return;
|
||||
} else if (event['type'] === "m.room.member") {
|
||||
// The reason we have to apply bans on each member change is because
|
||||
// we cannot eagerly ban users (that is to ban them when they have never been a member)
|
||||
// as they can be force joined to a room they might not have known existed.
|
||||
// Only apply bans and then redactions in the room we are currently looking at.
|
||||
const banErrors = await this.applyUserBans(this.policyLists, [roomId]);
|
||||
const redactionErrors = await this.processRedactionQueue(roomId);
|
||||
await this.printActionResult(banErrors);
|
||||
await this.printActionResult(redactionErrors);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync all the rooms with all the watched lists, banning and applying any changed ACLS.
|
||||
* @param verbose Whether to report any errors to the management room.
|
||||
*/
|
||||
public async syncLists(verbose = true) {
|
||||
for (const list of this.policyLists) {
|
||||
const changes = await list.updateList();
|
||||
await this.printBanlistChanges(changes, list, true);
|
||||
}
|
||||
|
||||
let hadErrors = false;
|
||||
const [aclErrors, banErrors] = await Promise.all([
|
||||
this.applyServerAcls(this.policyLists, this.protectedRoomsByActivity()),
|
||||
this.applyUserBans(this.policyLists, this.protectedRoomsByActivity())
|
||||
]);
|
||||
const redactionErrors = await this.processRedactionQueue();
|
||||
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
|
||||
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
|
||||
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
|
||||
|
||||
if (!hadErrors && verbose) {
|
||||
const html = `<font color="#00cc00">Done updating rooms - no errors</font>`;
|
||||
const text = "Done updating rooms - no errors";
|
||||
await this.client.sendMessage(this.managementRoomId, {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async addProtectedRoom(roomId: string): Promise<void> {
|
||||
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.protectedRoomActivityTracker.addProtectedRoom(roomId);
|
||||
await this.syncLists(this.config.verboseLogging);
|
||||
}
|
||||
|
||||
public removeProtectedRoom(roomId: string): void {
|
||||
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
|
||||
this.protectedRooms.delete(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls any changes to the rules that are in a policy room and updates all protected rooms
|
||||
* with those changes. Does not fail if there are errors updating the room, these are reported to the management room.
|
||||
* @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.
|
||||
*/
|
||||
public async syncWithPolicyList(policyList: PolicyList): Promise<void> {
|
||||
// this bit can move away into a listener.
|
||||
const changes = await policyList.updateList();
|
||||
|
||||
let hadErrors = false;
|
||||
const [aclErrors, banErrors] = await Promise.all([
|
||||
this.applyServerAcls(this.policyLists, this.protectedRoomsByActivity()),
|
||||
this.applyUserBans(this.policyLists, this.protectedRoomsByActivity())
|
||||
]);
|
||||
const redactionErrors = await this.processRedactionQueue();
|
||||
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
|
||||
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
|
||||
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
|
||||
|
||||
if (!hadErrors) {
|
||||
const html = `<font color="#00cc00"><b>Done updating rooms - no errors</b></font>`;
|
||||
const text = "Done updating rooms - no errors";
|
||||
await this.client.sendMessage(this.managementRoomId, {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
});
|
||||
}
|
||||
// This can fail if the change is very large and it is much less important than applying bans, so do it last.
|
||||
await this.printBanlistChanges(changes, policyList, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the server ACLs represented by the ban lists to the provided rooms, returning the
|
||||
* room IDs that could not be updated and their error.
|
||||
* Does not update the banLists before taking their rules to build the server ACL.
|
||||
* @param {PolicyList[]} lists The lists to construct ACLs from.
|
||||
* @param {string[]} roomIds The room IDs to apply the ACLs in.
|
||||
* @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with.
|
||||
*/
|
||||
private async applyServerAcls(lists: PolicyList[], roomIds: string[]): Promise<RoomUpdateError[]> {
|
||||
// we need to provide mutual exclusion so that we do not have requests updating the m.room.server_acl event
|
||||
// finish out of order and therefore leave the room out of sync with the policy lists.
|
||||
return new Promise((resolve, reject) => {
|
||||
this.aclChain = this.aclChain
|
||||
.then(() => this._applyServerAcls(lists, roomIds))
|
||||
.then(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
private async _applyServerAcls(lists: PolicyList[], roomIds: string[]): Promise<RoomUpdateError[]> {
|
||||
const serverName: string = new UserID(await this.client.getUserId()).domain;
|
||||
|
||||
// Construct a server ACL first
|
||||
const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*");
|
||||
for (const list of lists) {
|
||||
for (const rule of list.serverRules) {
|
||||
acl.denyServer(rule.entity);
|
||||
}
|
||||
}
|
||||
|
||||
const finalAcl = acl.safeAclContent();
|
||||
|
||||
if (finalAcl.deny.length !== acl.literalAclContent().deny.length) {
|
||||
this.managementRoomOutput.logMessage(LogLevel.WARN, "ApplyAcl", `Mjölnir has detected and removed an ACL that would exclude itself. Please check the ACL lists.`);
|
||||
}
|
||||
|
||||
if (this.config.verboseLogging) {
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
await this.client.sendNotice(this.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`);
|
||||
}
|
||||
|
||||
const errors: RoomUpdateError[] = [];
|
||||
for (const roomId of roomIds) {
|
||||
try {
|
||||
await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyAcl", `Checking ACLs for ${roomId}`, roomId);
|
||||
|
||||
try {
|
||||
const currentAcl = await this.client.getRoomStateEvent(roomId, "m.room.server_acl", "");
|
||||
if (acl.matches(currentAcl)) {
|
||||
await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyAcl", `Skipping ACLs for ${roomId} because they are already the right ones`, roomId);
|
||||
continue;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore - assume no ACL
|
||||
}
|
||||
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
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);
|
||||
} else {
|
||||
await this.managementRoomOutput.logMessage(LogLevel.WARN, "ApplyAcl", `Tried to apply ACL in ${roomId} but Mjolnir is running in no-op mode`, roomId);
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e.message || (e.body ? e.body.error : '<no message>');
|
||||
const kind = message && message.includes("You don't have permission to post that to the room") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL;
|
||||
errors.push({ roomId, errorMessage: message, errorKind: kind });
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the member bans represented by the ban lists to the provided rooms, returning the
|
||||
* room IDs that could not be updated and their error.
|
||||
* @param {PolicyList[]} lists The lists to determine bans from.
|
||||
* @param {string[]} roomIds The room IDs to apply the bans in.
|
||||
* @param {Mjolnir} mjolnir The Mjolnir client to apply the bans with.
|
||||
*/
|
||||
private async applyUserBans(lists: PolicyList[], roomIds: string[]): Promise<RoomUpdateError[]> {
|
||||
// We can only ban people who are not already banned, and who match the rules.
|
||||
const errors: RoomUpdateError[] = [];
|
||||
for (const roomId of roomIds) {
|
||||
try {
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
await this.managementRoomOutput.logMessage(LogLevel.DEBUG, "ApplyBan", `Updating member bans in ${roomId}`, roomId);
|
||||
|
||||
let members: { userId: string, membership: string }[];
|
||||
|
||||
if (this.config.fasterMembershipChecks) {
|
||||
const memberIds = await this.client.getJoinedRoomMembers(roomId);
|
||||
members = memberIds.map(u => {
|
||||
return { userId: u, membership: "join" };
|
||||
});
|
||||
} else {
|
||||
const state = await this.client.getRoomState(roomId);
|
||||
members = state.filter(s => s['type'] === 'm.room.member' && !!s['state_key']).map(s => {
|
||||
return { userId: s['state_key'], membership: s['content'] ? s['content']['membership'] : 'leave' };
|
||||
});
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
if (member.membership === 'ban') {
|
||||
continue; // user already banned
|
||||
}
|
||||
|
||||
let banned = false;
|
||||
for (const list of lists) {
|
||||
for (const userRule of list.userRules) {
|
||||
if (userRule.isMatch(member.userId)) {
|
||||
// User needs to be banned
|
||||
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
await this.managementRoomOutput.logMessage(LogLevel.INFO, "ApplyBan", `Banning ${member.userId} in ${roomId} for: ${userRule.reason}`, roomId);
|
||||
|
||||
if (!this.config.noop) {
|
||||
await this.client.banUser(member.userId, roomId, userRule.reason);
|
||||
if (this.automaticRedactGlobs.find(g => g.test(userRule.reason.toLowerCase()))) {
|
||||
this.redactUser(member.userId, roomId);
|
||||
}
|
||||
} else {
|
||||
await this.managementRoomOutput.logMessage(LogLevel.WARN, "ApplyBan", `Tried to ban ${member.userId} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
|
||||
}
|
||||
|
||||
banned = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (banned) break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e.message || (e.body ? e.body.error : '<no message>');
|
||||
errors.push({
|
||||
roomId,
|
||||
errorMessage: message,
|
||||
errorKind: message && message.includes("You don't have permission to ban") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the changes to a banlist to the management room.
|
||||
* @param changes A list of changes that have been made to a particular ban list.
|
||||
* @param ignoreSelf Whether to exclude changes that have been made by Mjolnir.
|
||||
* @returns true if the message was sent, false if it wasn't (because there there were no changes to report).
|
||||
*/
|
||||
private async printBanlistChanges(changes: ListRuleChange[], list: PolicyList, ignoreSelf = false): Promise<boolean> {
|
||||
if (ignoreSelf) {
|
||||
const sender = await this.client.getUserId();
|
||||
changes = changes.filter(change => change.sender !== sender);
|
||||
}
|
||||
if (changes.length <= 0) return false;
|
||||
|
||||
let html = "";
|
||||
let text = "";
|
||||
|
||||
const changesInfo = `updated with ${changes.length} ` + (changes.length === 1 ? 'change:' : 'changes:');
|
||||
const shortcodeInfo = list.listShortcode ? ` (shortcode: ${htmlEscape(list.listShortcode)})` : '';
|
||||
|
||||
html += `<a href="${htmlEscape(list.roomRef)}">${htmlEscape(list.roomId)}</a>${shortcodeInfo} ${changesInfo}<br/><ul>`;
|
||||
text += `${list.roomRef}${shortcodeInfo} ${changesInfo}:\n`;
|
||||
|
||||
for (const change of changes) {
|
||||
const rule = change.rule;
|
||||
let ruleKind: string = rule.kind;
|
||||
if (ruleKind === RULE_USER) {
|
||||
ruleKind = 'user';
|
||||
} else if (ruleKind === RULE_SERVER) {
|
||||
ruleKind = 'server';
|
||||
} else if (ruleKind === RULE_ROOM) {
|
||||
ruleKind = 'room';
|
||||
}
|
||||
html += `<li>${change.changeType} ${htmlEscape(ruleKind)} (<code>${htmlEscape(rule.recommendation ?? "")}</code>): <code>${htmlEscape(rule.entity)}</code> (${htmlEscape(rule.reason)})</li>`;
|
||||
text += `* ${change.changeType} ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`;
|
||||
}
|
||||
|
||||
const message = {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
};
|
||||
await this.client.sendMessage(this.managementRoomId, message);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async printActionResult(errors: RoomUpdateError[], title: string | null = null, logAnyways = false) {
|
||||
if (errors.length <= 0) return false;
|
||||
|
||||
if (!logAnyways) {
|
||||
errors = errors.filter(e => this.errorCache.triggerError(e.roomId, e.errorKind));
|
||||
if (errors.length <= 0) {
|
||||
LogService.warn("Mjolnir", "Multiple errors are happening, however they are muted. Please check the management room.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let html = "";
|
||||
let text = "";
|
||||
|
||||
const htmlTitle = title ? `${title}<br />` : '';
|
||||
const textTitle = title ? `${title}\n` : '';
|
||||
|
||||
html += `<font color="#ff0000"><b>${htmlTitle}${errors.length} errors updating protected rooms!</b></font><br /><ul>`;
|
||||
text += `${textTitle}${errors.length} errors updating protected rooms!\n`;
|
||||
const viaServers = [(new UserID(await this.client.getUserId())).domain];
|
||||
for (const error of errors) {
|
||||
const alias = (await this.client.getPublishedAlias(error.roomId)) || error.roomId;
|
||||
const url = Permalinks.forRoom(alias, viaServers);
|
||||
html += `<li><a href="${url}">${alias}</a> - ${error.errorMessage}</li>`;
|
||||
text += `${url} - ${error.errorMessage}\n`;
|
||||
}
|
||||
html += "</ul>";
|
||||
|
||||
const message = {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
};
|
||||
await this.client.sendMessage(this.managementRoomId, message);
|
||||
return true;
|
||||
}
|
||||
|
||||
public requiredProtectionPermissions() {
|
||||
throw new TypeError("Unimplemented, need to put protections into here too.")
|
||||
}
|
||||
|
||||
public async verifyPermissions(verbose = true, printRegardless = false) {
|
||||
const errors: RoomUpdateError[] = [];
|
||||
for (const roomId of this.protectedRooms) {
|
||||
errors.push(...(await this.protectionManager.verifyPermissionsIn(roomId)));
|
||||
}
|
||||
|
||||
const hadErrors = await this.printActionResult(errors, "Permission errors in protected rooms:", printRegardless);
|
||||
if (!hadErrors && verbose) {
|
||||
const html = `<font color="#00cc00">All permissions look OK.</font>`;
|
||||
const text = "All permissions look OK.";
|
||||
await this.client.sendMessage(this.managementRoomId, {
|
||||
msgtype: "m.notice",
|
||||
body: text,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -1,95 +0,0 @@
|
||||
/*
|
||||
Copyright 2019, 2020 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 PolicyList from "../models/PolicyList";
|
||||
import { ServerAcl } from "../models/ServerAcl";
|
||||
import { RoomUpdateError } from "../models/RoomUpdateError";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { LogLevel, UserID } from "matrix-bot-sdk";
|
||||
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
|
||||
|
||||
/**
|
||||
* Applies the server ACLs represented by the ban lists to the provided rooms, returning the
|
||||
* room IDs that could not be updated and their error.
|
||||
* Does not update the banLists before taking their rules to build the server ACL.
|
||||
* @param {PolicyList[]} lists The lists to construct ACLs from.
|
||||
* @param {string[]} roomIds The room IDs to apply the ACLs in.
|
||||
* @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with.
|
||||
*/
|
||||
export async function applyServerAcls(lists: PolicyList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> {
|
||||
// we need to provide mutual exclusion so that we do not have requests updating the m.room.server_acl event
|
||||
// finish out of order and therefore leave the room out of sync with the policy lists.
|
||||
return new Promise((resolve, reject) => {
|
||||
mjolnir.aclChain = mjolnir.aclChain
|
||||
.then(() => _applyServerAcls(lists, roomIds, mjolnir))
|
||||
.then(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function _applyServerAcls(lists: PolicyList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> {
|
||||
const serverName: string = new UserID(await mjolnir.client.getUserId()).domain;
|
||||
|
||||
// Construct a server ACL first
|
||||
const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*");
|
||||
for (const list of lists) {
|
||||
for (const rule of list.serverRules) {
|
||||
acl.denyServer(rule.entity);
|
||||
}
|
||||
}
|
||||
|
||||
const finalAcl = acl.safeAclContent();
|
||||
|
||||
if (finalAcl.deny.length !== acl.literalAclContent().deny.length) {
|
||||
mjolnir.logMessage(LogLevel.WARN, "ApplyAcl", `Mjölnir has detected and removed an ACL that would exclude itself. Please check the ACL lists.`);
|
||||
}
|
||||
|
||||
if (mjolnir.config.verboseLogging) {
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`);
|
||||
}
|
||||
|
||||
const errors: RoomUpdateError[] = [];
|
||||
for (const roomId of roomIds) {
|
||||
try {
|
||||
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Checking ACLs for ${roomId}`, roomId);
|
||||
|
||||
try {
|
||||
const currentAcl = await mjolnir.client.getRoomStateEvent(roomId, "m.room.server_acl", "");
|
||||
if (acl.matches(currentAcl)) {
|
||||
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Skipping ACLs for ${roomId} because they are already the right ones`, roomId);
|
||||
continue;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore - assume no ACL
|
||||
}
|
||||
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Applying ACL in ${roomId}`, roomId);
|
||||
|
||||
if (!mjolnir.config.noop) {
|
||||
await mjolnir.client.sendStateEvent(roomId, "m.room.server_acl", "", finalAcl);
|
||||
} else {
|
||||
await mjolnir.logMessage(LogLevel.WARN, "ApplyAcl", `Tried to apply ACL in ${roomId} but Mjolnir is running in no-op mode`, roomId);
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e.message || (e.body ? e.body.error : '<no message>');
|
||||
const kind = message && message.includes("You don't have permission to post that to the room") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL;
|
||||
errors.push({ roomId, errorMessage: message, errorKind: kind });
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
@ -1,93 +0,0 @@
|
||||
/*
|
||||
Copyright 2019, 2020 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 PolicyList from "../models/PolicyList";
|
||||
import { RoomUpdateError } from "../models/RoomUpdateError";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { LogLevel } from "matrix-bot-sdk";
|
||||
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
|
||||
|
||||
/**
|
||||
* Applies the member bans represented by the ban lists to the provided rooms, returning the
|
||||
* room IDs that could not be updated and their error.
|
||||
* @param {PolicyList[]} lists The lists to determine bans from.
|
||||
* @param {string[]} roomIds The room IDs to apply the bans in.
|
||||
* @param {Mjolnir} mjolnir The Mjolnir client to apply the bans with.
|
||||
*/
|
||||
export async function applyUserBans(lists: PolicyList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> {
|
||||
// We can only ban people who are not already banned, and who match the rules.
|
||||
const errors: RoomUpdateError[] = [];
|
||||
for (const roomId of roomIds) {
|
||||
try {
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyBan", `Updating member bans in ${roomId}`, roomId);
|
||||
|
||||
let members: { userId: string, membership: string }[];
|
||||
|
||||
if (mjolnir.config.fasterMembershipChecks) {
|
||||
const memberIds = await mjolnir.client.getJoinedRoomMembers(roomId);
|
||||
members = memberIds.map(u => {
|
||||
return { userId: u, membership: "join" };
|
||||
});
|
||||
} else {
|
||||
const state = await mjolnir.client.getRoomState(roomId);
|
||||
members = state.filter(s => s['type'] === 'm.room.member' && !!s['state_key']).map(s => {
|
||||
return { userId: s['state_key'], membership: s['content'] ? s['content']['membership'] : 'leave' };
|
||||
});
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
if (member.membership === 'ban') {
|
||||
continue; // user already banned
|
||||
}
|
||||
|
||||
let banned = false;
|
||||
for (const list of lists) {
|
||||
for (const userRule of list.userRules) {
|
||||
if (userRule.isMatch(member.userId)) {
|
||||
// User needs to be banned
|
||||
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
await mjolnir.logMessage(LogLevel.INFO, "ApplyBan", `Banning ${member.userId} in ${roomId} for: ${userRule.reason}`, roomId);
|
||||
|
||||
if (!mjolnir.config.noop) {
|
||||
await mjolnir.client.banUser(member.userId, roomId, userRule.reason);
|
||||
if (mjolnir.automaticRedactGlobs.find(g => g.test(userRule.reason.toLowerCase()))) {
|
||||
mjolnir.queueRedactUserMessagesIn(member.userId, roomId);
|
||||
}
|
||||
} else {
|
||||
await mjolnir.logMessage(LogLevel.WARN, "ApplyBan", `Tried to ban ${member.userId} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
|
||||
}
|
||||
|
||||
banned = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (banned) break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e.message || (e.body ? e.body.error : '<no message>');
|
||||
errors.push({
|
||||
roomId,
|
||||
errorMessage: message,
|
||||
errorKind: message && message.includes("You don't have permission to ban") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
@ -32,7 +32,7 @@ export async function execRemoveProtectedRoom(roomId: string, event: any, mjolni
|
||||
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.managementRoomOutput.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'], '✅');
|
||||
}
|
||||
|
@ -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 = mjolnir.protectedRoomsTracker.getProtectedRooms();
|
||||
|
||||
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.managementRoomOutput.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.managementRoomOutput.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.managementRoomOutput.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${victim} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 = `<b>Protected rooms (${Object.keys(mjolnir.protectedRooms).length}):</b><br/><ul>`;
|
||||
let text = `Protected rooms (${Object.keys(mjolnir.protectedRooms).length}):\n`;
|
||||
const rooms = mjolnir.protectedRoomsTracker.getProtectedRooms();
|
||||
let html = `<b>Protected rooms (${rooms.length}):</b><br/><ul>`;
|
||||
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 += `<li><a href="${roomUrl}">${protectedRoomId}</a></li>`;
|
||||
text += `* ${roomUrl}\n`;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -22,7 +22,7 @@ import { isListSetting } from "../protections/ProtectionSettings";
|
||||
// !mjolnir enable <protection>
|
||||
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<string> {
|
||||
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 <protection>
|
||||
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:<ul>";
|
||||
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 += `<li>${emoji} <code>${protectionName}</code> - ${protection.description}</li>`;
|
||||
text += `* ${emoji} ${protectionName} - ${protection.description}\n`;
|
||||
|
@ -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.getProtectedRooms();
|
||||
await redactUserMessagesIn(mjolnir.client, mjolnir.managementRoomOutput, userId, targetRoomIds, limit);
|
||||
|
||||
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
|
||||
await mjolnir.client.redactEvent(roomId, processingReactionId, 'done processing');
|
||||
|
@ -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.getProtectedRooms();
|
||||
|
||||
for (const targetRoomId of targetRooms) {
|
||||
try {
|
||||
await mjolnir.client.setUserPowerLevel(victim, targetRoomId, level);
|
||||
} catch (e) {
|
||||
const message = e.message || (e.body ? e.body.error : '<no message>');
|
||||
await mjolnir.logMessage(LogLevel.ERROR, "SetPowerLevelCommand", `Failed to set power level of ${victim} to ${level} in ${targetRoomId}: ${message}`, targetRoomId);
|
||||
await mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "SetPowerLevelCommand", `Failed to set power level of ${victim} to ${level} in ${targetRoomId}: ${message}`, targetRoomId);
|
||||
LogService.error("SetPowerLevelCommand", extractRequestError(e));
|
||||
}
|
||||
}
|
||||
|
@ -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.managementRoomOutput.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</* room id */string> = new Set();
|
||||
let reasonParts: string[] | undefined;
|
||||
const protectedRooms = new Set(mjolnir.protectedRoomsTracker.getProtectedRooms());
|
||||
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.getProtectedRooms()) {
|
||||
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.managementRoomOutput.logMessage(LogLevel.WARN, "SinceCommand", `This room is not protected: ${htmlEscape(roomId)}.`);
|
||||
}
|
||||
rooms.add(roomId);
|
||||
continue;
|
||||
|
@ -91,7 +91,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) {
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -131,21 +131,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.managementRoomOutput.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.getProtectedRooms()) {
|
||||
const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ['ban'], undefined);
|
||||
await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Found ${members.length} banned user(s)`);
|
||||
await mjolnir.managementRoomOutput.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.managementRoomOutput.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.managementRoomOutput.logMessage(LogLevel.WARN, "UnbanBanCommand", `Attempted to unban ${victim} in ${protectedRoomId} but Mjolnir is running in no-op mode`, protectedRoomId);
|
||||
}
|
||||
|
||||
unbannedSomeone = true;
|
||||
@ -154,8 +154,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.managementRoomOutput.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Syncing lists to ensure no users were accidentally unbanned`);
|
||||
await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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.managementRoomOutput.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.managementRoomOutput.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.managementRoomOutput.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
|
||||
|
@ -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.managementRoomOutput.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.managementRoomOutput.logMessage(LogLevel.INFO, "FederationLag",
|
||||
`Room ${roomId} lag has decreased to an acceptable level. Currently, ${roomInfo.alerts} homeservers are still lagging`
|
||||
);
|
||||
if (roomInfo.warnStateEventId) {
|
||||
|
@ -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('<img');
|
||||
if (isMedia && this.justJoined[roomId].includes(event['sender'])) {
|
||||
await mjolnir.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Banning ${event['sender']} for posting an image as the first thing after joining in ${roomId}.`);
|
||||
await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Banning ${event['sender']} for posting an image as the first thing after joining in ${roomId}.`);
|
||||
if (!mjolnir.config.noop) {
|
||||
await mjolnir.client.banUser(event['sender'], roomId, "spam");
|
||||
} else {
|
||||
await mjolnir.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
|
||||
await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "FirstMessageIsImage", `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)
|
||||
@ -71,7 +71,7 @@ export class FirstMessageIsImage extends Protection {
|
||||
if (!mjolnir.config.noop) {
|
||||
await mjolnir.client.redactEvent(roomId, event['event_id'], "spam");
|
||||
} else {
|
||||
await mjolnir.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
|
||||
await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -56,7 +56,7 @@ export class JoinWaveShortCircuit extends Protection {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(roomId in mjolnir.protectedRooms)) {
|
||||
if (!mjolnir.protectedRoomsTracker.isProtectedRoom(roomId)) {
|
||||
// Not a room we are watching.
|
||||
return;
|
||||
}
|
||||
@ -86,12 +86,12 @@ export class JoinWaveShortCircuit extends Protection {
|
||||
}
|
||||
|
||||
if (++this.joinBuckets[roomId].numberOfJoins >= 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.managementRoomOutput.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.managementRoomOutput.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Tried to set ${roomId} to invite-only, but Mjolnir is running in no-op mode`, roomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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('<img');
|
||||
if (isMedia) {
|
||||
await mjolnir.logMessage(LogLevel.WARN, "MessageIsMedia", `Redacting event from ${event['sender']} for posting an image/video. ${Permalinks.forEvent(roomId, event['event_id'], [new UserID(await mjolnir.client.getUserId()).domain])}`);
|
||||
await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "MessageIsMedia", `Redacting event from ${event['sender']} for posting an image/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'], "Images/videos are not permitted here");
|
||||
} else {
|
||||
await mjolnir.logMessage(LogLevel.WARN, "MessageIsMedia", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
|
||||
await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "MessageIsMedia", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -37,12 +37,12 @@ export class MessageIsVoice extends Protection {
|
||||
if (event['type'] === 'm.room.message' && event['content']) {
|
||||
if (event['content']['msgtype'] !== 'm.audio') return;
|
||||
if (event['content']['org.matrix.msc3245.voice'] === undefined) return;
|
||||
await mjolnir.logMessage(LogLevel.INFO, "MessageIsVoice", `Redacting event from ${event['sender']} for posting a voice message. ${Permalinks.forEvent(roomId, event['event_id'], [new UserID(await mjolnir.client.getUserId()).domain])}`);
|
||||
await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "MessageIsVoice", `Redacting event from ${event['sender']} for posting a voice message. ${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'], "Voice messages are not permitted here");
|
||||
} else {
|
||||
await mjolnir.logMessage(LogLevel.WARN, "MessageIsVoice", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
|
||||
await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "MessageIsVoice", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
397
src/protections/ProtectionManager.ts
Normal file
397
src/protections/ProtectionManager.ts
Normal file
@ -0,0 +1,397 @@
|
||||
/*
|
||||
Copyright 2019 - 2022 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 { FirstMessageIsImage } from "./FirstMessageIsImage";
|
||||
import { Protection } from "./IProtection";
|
||||
import { BasicFlooding } from "./BasicFlooding";
|
||||
import { DetectFederationLag } from "./DetectFederationLag";
|
||||
import { WordList } from "./WordList";
|
||||
import { MessageIsVoice } from "./MessageIsVoice";
|
||||
import { MessageIsMedia } from "./MessageIsMedia";
|
||||
import { TrustedReporters } from "./TrustedReporters";
|
||||
import { JoinWaveShortCircuit } from "./JoinWaveShortCircuit";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { extractRequestError, LogLevel, LogService, Permalinks } from "matrix-bot-sdk";
|
||||
import { ProtectionSettingValidationError } from "./ProtectionSettings";
|
||||
import { Consequence } from "./consequence";
|
||||
import { htmlEscape } from "../utils";
|
||||
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
|
||||
import { RoomUpdateError } from "../models/RoomUpdateError";
|
||||
|
||||
const PROTECTIONS: Protection[] = [
|
||||
new FirstMessageIsImage(),
|
||||
new BasicFlooding(),
|
||||
new WordList(),
|
||||
new MessageIsVoice(),
|
||||
new MessageIsMedia(),
|
||||
new TrustedReporters(),
|
||||
new DetectFederationLag(),
|
||||
new JoinWaveShortCircuit(),
|
||||
];
|
||||
|
||||
const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections";
|
||||
const CONSEQUENCE_EVENT_DATA = "org.matrix.mjolnir.consequence";
|
||||
|
||||
/**
|
||||
* This is responsible for informing protections about relevant events and handle standard consequences.
|
||||
*/
|
||||
export class ProtectionManager {
|
||||
private _protections = new Map<string /* protection name */, Protection>();
|
||||
get protections(): Readonly<Map<string /* protection name */, Protection>> {
|
||||
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.managementRoomOutput.logMessage(LogLevel.WARN, "ProtectionManager", extractRequestError(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a protection object; add it to our list of protections, set it up if it has been enabled previously (in account data)
|
||||
* and update its settings with any saved non-default values. See `ENABLED_PROTECTIONS_EVENT_TYPE`.
|
||||
*
|
||||
* @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 (!(this._protections.has(protectionName))) {
|
||||
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<any> {
|
||||
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.managementRoomOutput.logMessage(
|
||||
LogLevel.WARN,
|
||||
"getProtectionSetting",
|
||||
`Tried to read ${protectionName}.${key} and got invalid value ${value}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return validatedSettings;
|
||||
}
|
||||
|
||||
private async handleConsequences(protection: Protection, roomId: string, eventId: string, sender: string, consequences: Consequence[]) {
|
||||
for (const consequence of consequences) {
|
||||
try {
|
||||
if (consequence.name === "alert") {
|
||||
/* take no additional action, just print the below message to management room */
|
||||
} else if (consequence.name === "ban") {
|
||||
await this.mjolnir.client.banUser(sender, roomId, "abuse detected");
|
||||
} else if (consequence.name === "redact") {
|
||||
await this.mjolnir.client.redactEvent(roomId, eventId, "abuse detected");
|
||||
} else {
|
||||
throw new Error(`unknown consequence ${consequence.name}`);
|
||||
}
|
||||
|
||||
let message = `protection ${protection.name} enacting`
|
||||
+ ` ${consequence.name}`
|
||||
+ ` against ${htmlEscape(sender)}`
|
||||
+ ` in ${htmlEscape(roomId)}`
|
||||
+ ` (reason: ${htmlEscape(consequence.reason)})`;
|
||||
await this.mjolnir.client.sendMessage(this.mjolnir.managementRoomId, {
|
||||
msgtype: "m.notice",
|
||||
body: message,
|
||||
[CONSEQUENCE_EVENT_DATA]: {
|
||||
who: sender,
|
||||
room: roomId,
|
||||
types: [consequence.name],
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
await this.mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "handleConsequences", `Failed to enact ${consequence.name} consequence: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleEvent(roomId: string, event: any) {
|
||||
if (this.mjolnir.protectedRoomsTracker.getProtectedRooms().includes(roomId)) {
|
||||
if (event['sender'] === await this.mjolnir.client.getUserId()) return; // Ignore ourselves
|
||||
|
||||
// Iterate all the enabled protections
|
||||
for (const protection of this.enabledProtections) {
|
||||
let consequences: Consequence[] | undefined = undefined;
|
||||
try {
|
||||
consequences = 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 (consequences !== undefined) {
|
||||
await this.handleConsequences(protection, roomId, event["event_id"], event["sender"], consequences);
|
||||
}
|
||||
}
|
||||
|
||||
// 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<string> {
|
||||
return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat())
|
||||
}
|
||||
|
||||
public async verifyPermissionsIn(roomId: string): Promise<RoomUpdateError[]> {
|
||||
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 : '<no message>'),
|
||||
errorKind: ERROR_KIND_FATAL,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
@ -92,7 +92,7 @@ export class WordList extends Protection {
|
||||
"i"
|
||||
);
|
||||
} catch (ex) {
|
||||
await mjolnir.logMessage(LogLevel.ERROR, "WordList", `Could not produce a regex from the word list:\n${ex}.`)
|
||||
await mjolnir.managementRoomOutput.logMessage(LogLevel.ERROR, "WordList", `Could not produce a regex from the word list:\n${ex}.`)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,36 +0,0 @@
|
||||
/*
|
||||
Copyright 2019 - 2022 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 { FirstMessageIsImage } from "./FirstMessageIsImage";
|
||||
import { Protection } from "./IProtection";
|
||||
import { BasicFlooding } from "./BasicFlooding";
|
||||
import { DetectFederationLag } from "./DetectFederationLag";
|
||||
import { WordList } from "./WordList";
|
||||
import { MessageIsVoice } from "./MessageIsVoice";
|
||||
import { MessageIsMedia } from "./MessageIsMedia";
|
||||
import { TrustedReporters } from "./TrustedReporters";
|
||||
import { JoinWaveShortCircuit } from "./JoinWaveShortCircuit";
|
||||
|
||||
export const PROTECTIONS: Protection[] = [
|
||||
new FirstMessageIsImage(),
|
||||
new BasicFlooding(),
|
||||
new WordList(),
|
||||
new MessageIsVoice(),
|
||||
new MessageIsMedia(),
|
||||
new TrustedReporters(),
|
||||
new DetectFederationLag(),
|
||||
new JoinWaveShortCircuit(),
|
||||
];
|
@ -13,11 +13,11 @@ 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 { LogLevel } from "matrix-bot-sdk"
|
||||
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 "../ManagementRoomOutput";
|
||||
|
||||
export interface QueuedRedaction {
|
||||
/** The room which the redaction will take place in. */
|
||||
@ -27,7 +27,7 @@ export interface QueuedRedaction {
|
||||
* Called by the EventRedactionQueue.
|
||||
* @param client A MatrixClient to use to carry out the redaction.
|
||||
*/
|
||||
redact(mjolnir: Mjolnir): Promise<void>
|
||||
redact(client: MatrixClient, managementRoom: ManagementRoomOutput): Promise<void>
|
||||
/**
|
||||
* Used to test whether the redaction is the equivalent to another redaction.
|
||||
* @param redaction Another QueuedRedaction to test if this redaction is an equivalent to.
|
||||
@ -47,9 +47,9 @@ export class RedactUserInRoom implements QueuedRedaction {
|
||||
this.roomId = roomId;
|
||||
}
|
||||
|
||||
public async redact(mjolnir: Mjolnir) {
|
||||
await mjolnir.logMessage(LogLevel.DEBUG, "Mjolnir", `Redacting events from ${this.userId} in room ${this.roomId}.`);
|
||||
await redactUserMessagesIn(mjolnir, this.userId, [this.roomId]);
|
||||
public async redact(client: MatrixClient, managementRoom: ManagementRoomOutput) {
|
||||
await managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir", `Redacting events from ${this.userId} in room ${this.roomId}.`);
|
||||
await redactUserMessagesIn(client, managementRoom, this.userId, [this.roomId]);
|
||||
}
|
||||
|
||||
public redactionEqual(redaction: QueuedRedaction): boolean {
|
||||
@ -107,12 +107,12 @@ export class EventRedactionQueue {
|
||||
* @param limitToRoomId If the roomId is provided, only redactions for that room will be processed.
|
||||
* @returns A description of any errors encountered by each QueuedRedaction that was processed.
|
||||
*/
|
||||
public async process(mjolnir: Mjolnir, limitToRoomId?: string): Promise<RoomUpdateError[]> {
|
||||
public async process(client: MatrixClient, managementRoom: ManagementRoomOutput, limitToRoomId?: string): Promise<RoomUpdateError[]> {
|
||||
const errors: RoomUpdateError[] = [];
|
||||
const redact = async (currentBatch: QueuedRedaction[]) => {
|
||||
for (const redaction of currentBatch) {
|
||||
try {
|
||||
await redaction.redact(mjolnir);
|
||||
await redaction.redact(client, managementRoom);
|
||||
} catch (e) {
|
||||
let roomError: RoomUpdateError;
|
||||
if (e.roomId && e.errorMessage && e.errorKind) {
|
||||
|
@ -13,7 +13,6 @@ 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 "matrix-bot-sdk";
|
||||
|
||||
/**
|
||||
* Used to keep track of protected rooms so they are always ordered for activity.
|
||||
@ -29,9 +28,6 @@ export class ProtectedRoomActivityTracker {
|
||||
* A slot to cache the rooms for `protectedRoomsByActivity` ordered so the most recently active room is first.
|
||||
*/
|
||||
private activeRoomsCache: null|string[] = null
|
||||
constructor(client: MatrixClient) {
|
||||
client.on('room.event', this.handleEvent.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Inform the tracker that a new room is being protected by Mjolnir.
|
||||
@ -55,6 +51,7 @@ export class ProtectedRoomActivityTracker {
|
||||
* Inform the tracker of a new event in a room, so that the internal ranking of rooms can be updated
|
||||
* @param roomId The room the new event is in.
|
||||
* @param event The new event.
|
||||
*
|
||||
*/
|
||||
public handleEvent(roomId: string, event: any): void {
|
||||
const last_origin_server_ts = this.protectedRoomActivities.get(roomId);
|
||||
|
@ -178,7 +178,7 @@ export class ThrottlingQueue {
|
||||
try {
|
||||
await task();
|
||||
} catch (ex) {
|
||||
await this.mjolnir.logMessage(
|
||||
await this.mjolnir.managementRoomOutput.logMessage(
|
||||
LogLevel.WARN,
|
||||
'Error while executing task',
|
||||
extractRequestError(ex)
|
||||
|
@ -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.managementRoomOutput.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.managementRoomOutput.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Unable to redact message: ${permalink}`);
|
||||
LogService.warn("AutomaticRedactionQueue", extractRequestError(e));
|
||||
}
|
||||
}
|
||||
|
@ -75,13 +75,13 @@ export class ReportPoller {
|
||||
}
|
||||
);
|
||||
} catch (ex) {
|
||||
await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to poll events: ${ex}`);
|
||||
await this.mjolnir.managementRoomOutput.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.managementRoomOutput.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.managementRoomOutput.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.managementRoomOutput.logMessage(LogLevel.ERROR, "tryGetAbuseReports", `failed to get abuse reports: ${ex}`);
|
||||
}
|
||||
|
||||
this.schedulePoll();
|
||||
|
78
src/utils.ts
78
src/utils.ts
@ -15,21 +15,16 @@ 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 "./ManagementRoomOutput";
|
||||
|
||||
// Define a few aliases to simplify parsing durations.
|
||||
|
||||
@ -70,17 +65,29 @@ export function isTrueJoinEvent(event: any): boolean {
|
||||
return membership === 'join' && prevMembership !== "join";
|
||||
}
|
||||
|
||||
export async function redactUserMessagesIn(mjolnir: Mjolnir, userIdOrGlob: string, targetRoomIds: string[], limit = 1000) {
|
||||
/**
|
||||
* Redact a user's messages in a set of rooms.
|
||||
* See `getMessagesByUserIn`.
|
||||
*
|
||||
* @param client Client to redact the messages with.
|
||||
* @param managementRoom Management room to log messages back to.
|
||||
* @param userIdOrGlob A mxid or a glob which is applied to the whole sender field of events in the room, which will be redacted if they match.
|
||||
* See `MatrixGlob` in matrix-bot-sdk.
|
||||
* @param targetRoomIds Rooms to redact the messages from.
|
||||
* @param limit The number of messages to redact from most recent first. If the limit is reached then no further messages will be redacted.
|
||||
* @param noop Whether to operate in noop mode.
|
||||
*/
|
||||
export async function redactUserMessagesIn(client: MatrixClient, managementRoom: ManagementRoomOutput, userIdOrGlob: string, targetRoomIds: string[], limit = 1000, noop = false) {
|
||||
for (const targetRoomId of targetRoomIds) {
|
||||
await mjolnir.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Fetching sent messages for ${userIdOrGlob} in ${targetRoomId} to redact...`, targetRoomId);
|
||||
await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Fetching sent messages for ${userIdOrGlob} in ${targetRoomId} to redact...`, targetRoomId);
|
||||
|
||||
await getMessagesByUserIn(mjolnir.client, userIdOrGlob, targetRoomId, limit, async (eventsToRedact) => {
|
||||
await getMessagesByUserIn(client, userIdOrGlob, targetRoomId, limit, async (eventsToRedact) => {
|
||||
for (const victimEvent of eventsToRedact) {
|
||||
await mjolnir.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Redacting ${victimEvent['event_id']} in ${targetRoomId}`, targetRoomId);
|
||||
if (!mjolnir.config.noop) {
|
||||
await mjolnir.client.redactEvent(targetRoomId, victimEvent['event_id']);
|
||||
await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Redacting ${victimEvent['event_id']} in ${targetRoomId}`, targetRoomId);
|
||||
if (!noop) {
|
||||
await client.redactEvent(targetRoomId, victimEvent['event_id']);
|
||||
} else {
|
||||
await mjolnir.logMessage(LogLevel.WARN, "utils#redactUserMessagesIn", `Tried to redact ${victimEvent['event_id']} in ${targetRoomId} but Mjolnir is running in no-op mode`, targetRoomId);
|
||||
await managementRoom.logMessage(LogLevel.WARN, "utils#redactUserMessagesIn", `Tried to redact ${victimEvent['event_id']} in ${targetRoomId} but Mjolnir is running in no-op mode`, targetRoomId);
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -189,51 +196,6 @@ export async function getMessagesByUserIn(client: MatrixClient, sender: string,
|
||||
} while (token && processed < limit)
|
||||
}
|
||||
|
||||
/*
|
||||
* Take an arbitrary string and a set of room IDs, and return a
|
||||
* TextualMessageEventContent whose plaintext component replaces those room
|
||||
* IDs with their canonical aliases, and whose html component replaces those
|
||||
* room IDs with their matrix.to room pills.
|
||||
*
|
||||
* @param client The matrix client on which to query for room aliases
|
||||
* @param text An arbitrary string to rewrite with room aliases and pills
|
||||
* @param roomIds A set of room IDs to find and replace in `text`
|
||||
* @param msgtype The desired message type of the returned TextualMessageEventContent
|
||||
* @returns A TextualMessageEventContent with replaced room IDs
|
||||
*/
|
||||
export async function replaceRoomIdsWithPills(mjolnir: Mjolnir, text: string, roomIds: Set<string>, msgtype: MessageType = "m.text"): Promise<TextualMessageEventContent> {
|
||||
const content: TextualMessageEventContent = {
|
||||
body: text,
|
||||
formatted_body: htmlEscape(text),
|
||||
msgtype: msgtype,
|
||||
format: "org.matrix.custom.html",
|
||||
};
|
||||
|
||||
const escapeRegex = (v: string): string => {
|
||||
return v.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
};
|
||||
|
||||
const viaServers = [(new UserID(await mjolnir.client.getUserId())).domain];
|
||||
for (const roomId of roomIds) {
|
||||
let alias = roomId;
|
||||
try {
|
||||
alias = (await mjolnir.client.getPublishedAlias(roomId)) || roomId;
|
||||
} catch (e) {
|
||||
// This is a recursive call, so tell the function not to try and call us
|
||||
await mjolnir.logMessage(LogLevel.WARN, "utils", `Failed to resolve room alias for ${roomId} - see console for details`, null, true);
|
||||
LogService.warn("utils", extractRequestError(e));
|
||||
}
|
||||
const regexRoomId = new RegExp(escapeRegex(roomId), "g");
|
||||
content.body = content.body.replace(regexRoomId, alias);
|
||||
if (content.formatted_body) {
|
||||
const permalink = Permalinks.forRoom(alias, alias !== roomId ? [] : viaServers);
|
||||
content.formatted_body = content.formatted_body.replace(regexRoomId, `<a href="${permalink}">${alias}</a>`);
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
let isMatrixClientPatchedForConciseExceptions = false;
|
||||
|
||||
/**
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -247,7 +247,7 @@ describe('Test: ACL updates will batch when rules are added in succession.', fun
|
||||
}
|
||||
|
||||
// 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();
|
||||
@ -312,7 +312,7 @@ describe('Test: unbaning entities via the PolicyList.', function() {
|
||||
await mjolnir.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.');
|
||||
@ -333,7 +333,7 @@ describe('Test: unbaning entities via the PolicyList.', function() {
|
||||
await createPolicyRule(moderator, banListId, RULE_SERVER, newerBadServer, 'this is bad sort it out.');
|
||||
await createPolicyRule(moderator, banListId, RULE_SERVER, newerBadServer, 'hidden with a non-standard state key', undefined, "rule_1");
|
||||
// Wait for the ACL event to be applied to our protected room.
|
||||
await this.mjolnir!.syncLists();
|
||||
await mjolnir.protectedRoomsTracker.syncLists();
|
||||
|
||||
await banList.updateList();
|
||||
// rules are normalized by rule type, that's why there should only be 3.
|
||||
@ -359,7 +359,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);
|
||||
@ -388,7 +388,7 @@ describe('Test: should apply bans to the most recently active rooms first', func
|
||||
}
|
||||
|
||||
// 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.');
|
||||
@ -399,7 +399,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--) {
|
||||
@ -408,25 +408,38 @@ describe('Test: should apply bans to the most recently active rooms first', func
|
||||
}
|
||||
// create some activity in the same order.
|
||||
for (const roomId of protectedRooms.slice().reverse()) {
|
||||
await mjolnir.client.sendMessage(roomId, { body: `activity`, msgtype: 'm.text' });
|
||||
await moderator.sendMessage(roomId, { body: `activity`, msgtype: 'm.text' });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// 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`;
|
||||
// just ban one server
|
||||
const badServer = `evil.com`;
|
||||
const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*").denyServer(badServer);
|
||||
await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule ${badServer}`);
|
||||
// collect all the rooms that received an ACL event.
|
||||
const aclRooms: any[] = await new Promise(async resolve => {
|
||||
const rooms: any[] = [];
|
||||
this.mjolnir.client.on('room.event', (room: string, event: any) => {
|
||||
if (protectedRooms.includes(room)) {
|
||||
rooms.push(room);
|
||||
}
|
||||
if (rooms.length === protectedRooms.length) {
|
||||
resolve(rooms)
|
||||
}
|
||||
});
|
||||
// create the rule that will ban the server.
|
||||
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(aclRooms[i], protectedRooms[i], "The ACL should have been applied to the active rooms first.");
|
||||
}
|
||||
|
||||
// Check that the most recently active rooms got the ACL update first.
|
||||
|
@ -24,8 +24,8 @@ describe("Test: DetectFederationLag protection", function() {
|
||||
beforeEach(async function() {
|
||||
// Setup an instance of DetectFederationLag
|
||||
this.detector = new DetectFederationLag();
|
||||
await this.mjolnir.registerProtection(this.detector);
|
||||
await this.mjolnir.enableProtection("DetectFederationLag");
|
||||
await this.mjolnir.protectionManager.registerProtection(this.detector);
|
||||
await this.mjolnir.protectionManager.enableProtection("DetectFederationLag");
|
||||
|
||||
// Setup a moderator.
|
||||
this.moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
|
||||
|
@ -2,7 +2,6 @@ import { strict as assert } from "assert";
|
||||
|
||||
import { Mjolnir } from "../../src/Mjolnir";
|
||||
import { IProtection } from "../../src/protections/IProtection";
|
||||
import { PROTECTIONS } from "../../src/protections/protections";
|
||||
import { ProtectionSettingValidationError } from "../../src/protections/ProtectionSettings";
|
||||
import { NumberProtectionSetting, StringProtectionSetting, StringListProtectionSetting } from "../../src/protections/ProtectionSettings";
|
||||
import { newTestUser, noticeListener } from "./clientHelper";
|
||||
@ -20,29 +19,29 @@ describe("Test: Protection settings", function() {
|
||||
it("Mjolnir refuses to save invalid protection setting values", async function() {
|
||||
this.timeout(20000);
|
||||
await assert.rejects(
|
||||
async () => await this.mjolnir.setProtectionSettings("BasicFloodingProtection", {"maxPerMinute": "soup"}),
|
||||
async () => await this.mjolnir.protectionManager.setProtectionSettings("BasicFloodingProtection", {"maxPerMinute": "soup"}),
|
||||
ProtectionSettingValidationError
|
||||
);
|
||||
});
|
||||
it("Mjolnir successfully saves valid protection setting values", async function() {
|
||||
this.timeout(20000);
|
||||
|
||||
await this.mjolnir.registerProtection(new class implements IProtection {
|
||||
await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
|
||||
name = "05OVMS";
|
||||
description = "A test protection";
|
||||
settings = { test: new NumberProtectionSetting(3) };
|
||||
});
|
||||
|
||||
await this.mjolnir.setProtectionSettings("05OVMS", { test: 123 });
|
||||
await this.mjolnir.protectionManager.setProtectionSettings("05OVMS", { test: 123 });
|
||||
assert.equal(
|
||||
(await this.mjolnir.getProtectionSettings("05OVMS"))["test"],
|
||||
(await this.mjolnir.protectionManager.getProtectionSettings("05OVMS"))["test"],
|
||||
123
|
||||
);
|
||||
});
|
||||
it("Mjolnir should accumulate changed settings", async function() {
|
||||
this.timeout(20000);
|
||||
|
||||
await this.mjolnir.registerProtection(new class implements IProtection {
|
||||
await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
|
||||
name = "HPUjKN";
|
||||
settings = {
|
||||
test1: new NumberProtectionSetting(3),
|
||||
@ -50,9 +49,9 @@ describe("Test: Protection settings", function() {
|
||||
};
|
||||
});
|
||||
|
||||
await this.mjolnir.setProtectionSettings("HPUjKN", { test1: 1 });
|
||||
await this.mjolnir.setProtectionSettings("HPUjKN", { test2: 2 });
|
||||
const settings = await this.mjolnir.getProtectionSettings("HPUjKN");
|
||||
await this.mjolnir.protectionManager.setProtectionSettings("HPUjKN", { test1: 1 });
|
||||
await this.mjolnir.protectionManager.setProtectionSettings("HPUjKN", { test2: 2 });
|
||||
const settings = await this.mjolnir.protectionManager.getProtectionSettings("HPUjKN");
|
||||
assert.equal(settings["test1"], 1);
|
||||
assert.equal(settings["test2"], 2);
|
||||
});
|
||||
@ -60,7 +59,7 @@ describe("Test: Protection settings", function() {
|
||||
this.timeout(20000);
|
||||
await client.joinRoom(this.config.managementRoom);
|
||||
|
||||
await this.mjolnir.registerProtection(new class implements IProtection {
|
||||
await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
|
||||
name = "JY2TPN";
|
||||
description = "A test protection";
|
||||
settings = { test: new StringProtectionSetting() };
|
||||
@ -78,14 +77,14 @@ describe("Test: Protection settings", function() {
|
||||
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config set JY2TPN.test asd"})
|
||||
await reply
|
||||
|
||||
const settings = await this.mjolnir.getProtectionSettings("JY2TPN");
|
||||
const settings = await this.mjolnir.protectionManager.getProtectionSettings("JY2TPN");
|
||||
assert.equal(settings["test"], "asd");
|
||||
});
|
||||
it("Mjolnir adds a value to a list setting", async function() {
|
||||
this.timeout(20000);
|
||||
await client.joinRoom(this.config.managementRoom);
|
||||
|
||||
await this.mjolnir.registerProtection(new class implements IProtection {
|
||||
await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
|
||||
name = "r33XyT";
|
||||
description = "A test protection";
|
||||
settings = { test: new StringListProtectionSetting() };
|
||||
@ -103,13 +102,13 @@ describe("Test: Protection settings", function() {
|
||||
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config add r33XyT.test asd"})
|
||||
await reply
|
||||
|
||||
assert.deepEqual(await this.mjolnir.getProtectionSettings("r33XyT"), { "test": ["asd"] });
|
||||
assert.deepEqual(await this.mjolnir.protectionManager.getProtectionSettings("r33XyT"), { "test": ["asd"] });
|
||||
});
|
||||
it("Mjolnir removes a value from a list setting", async function() {
|
||||
this.timeout(20000);
|
||||
await client.joinRoom(this.config.managementRoom);
|
||||
|
||||
await this.mjolnir.registerProtection(new class implements IProtection {
|
||||
await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
|
||||
name = "oXzT0E";
|
||||
description = "A test protection";
|
||||
settings = { test: new StringListProtectionSetting() };
|
||||
@ -128,13 +127,13 @@ describe("Test: Protection settings", function() {
|
||||
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config remove oXzT0E.test asd"})
|
||||
await reply();
|
||||
|
||||
assert.deepEqual(await this.mjolnir.getProtectionSettings("oXzT0E"), { "test": [] });
|
||||
assert.deepEqual(await this.mjolnir.protectionManager.getProtectionSettings("oXzT0E"), { "test": [] });
|
||||
});
|
||||
it("Mjolnir will change a protection setting in-place", async function() {
|
||||
this.timeout(20000);
|
||||
await client.joinRoom(this.config.managementRoom);
|
||||
|
||||
await this.mjolnir.registerProtection(new class implements IProtection {
|
||||
await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
|
||||
name = "d0sNrt";
|
||||
description = "A test protection";
|
||||
settings = { test: new StringProtectionSetting() };
|
||||
|
@ -16,7 +16,7 @@ describe("Test: Report polling", function() {
|
||||
|
||||
const eventId = await client.sendMessage(protectedRoomId, {msgtype: "m.text", body: "uwNd3q"});
|
||||
await new Promise(async resolve => {
|
||||
await this.mjolnir.registerProtection(new class implements IProtection {
|
||||
await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
|
||||
name = "jYvufI";
|
||||
description = "A test protection";
|
||||
settings = { };
|
||||
@ -27,7 +27,7 @@ describe("Test: Report polling", function() {
|
||||
}
|
||||
};
|
||||
});
|
||||
await this.mjolnir.enableProtection("jYvufI");
|
||||
await this.mjolnir.protectionManager.enableProtection("jYvufI");
|
||||
await client.doRequest(
|
||||
"POST",
|
||||
`/_matrix/client/r0/rooms/${encodeURIComponent(protectedRoomId)}/report/${encodeURIComponent(eventId)}`, "", {
|
||||
|
@ -27,7 +27,7 @@ describe("Test: standard consequences", function() {
|
||||
await badUser.joinRoom(protectedRoomId);
|
||||
await this.mjolnir.addProtectedRoom(protectedRoomId);
|
||||
|
||||
await this.mjolnir.registerProtection(new class implements IProtection {
|
||||
await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
|
||||
name = "JY2TPN";
|
||||
description = "A test protection";
|
||||
settings = { };
|
||||
@ -37,7 +37,7 @@ describe("Test: standard consequences", function() {
|
||||
}
|
||||
};
|
||||
});
|
||||
await this.mjolnir.enableProtection("JY2TPN");
|
||||
await this.mjolnir.protectionManager.enableProtection("JY2TPN");
|
||||
|
||||
let reply = new Promise(async (resolve, reject) => {
|
||||
const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "ngmWkF"});
|
||||
@ -71,7 +71,7 @@ describe("Test: standard consequences", function() {
|
||||
await badUser.joinRoom(protectedRoomId);
|
||||
await this.mjolnir.addProtectedRoom(protectedRoomId);
|
||||
|
||||
await this.mjolnir.registerProtection(new class implements IProtection {
|
||||
await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
|
||||
name = "0LxMTy";
|
||||
description = "A test protection";
|
||||
settings = { };
|
||||
@ -81,7 +81,7 @@ describe("Test: standard consequences", function() {
|
||||
}
|
||||
};
|
||||
});
|
||||
await this.mjolnir.enableProtection("0LxMTy");
|
||||
await this.mjolnir.protectionManager.enableProtection("0LxMTy");
|
||||
|
||||
let reply = new Promise(async (resolve, reject) => {
|
||||
const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "7Uga3d"});
|
||||
@ -118,7 +118,7 @@ describe("Test: standard consequences", function() {
|
||||
await goodUser.joinRoom(protectedRoomId);
|
||||
await this.mjolnir.addProtectedRoom(protectedRoomId);
|
||||
|
||||
await this.mjolnir.registerProtection(new class implements IProtection {
|
||||
await this.mjolnir.protectionManager.registerProtection(new class implements IProtection {
|
||||
name = "95B1Cr";
|
||||
description = "A test protection";
|
||||
settings = { };
|
||||
@ -128,7 +128,7 @@ describe("Test: standard consequences", function() {
|
||||
}
|
||||
};
|
||||
});
|
||||
await this.mjolnir.enableProtection("95B1Cr");
|
||||
await this.mjolnir.protectionManager.enableProtection("95B1Cr");
|
||||
|
||||
let reply = new Promise(async (resolve, reject) => {
|
||||
this.mjolnir.client.on('room.message', async (roomId, event) => {
|
||||
|
@ -1,15 +1,11 @@
|
||||
import { strict as assert } from "assert";
|
||||
|
||||
import { UserID } from "matrix-bot-sdk";
|
||||
import config from "../../src/config";
|
||||
import { replaceRoomIdsWithPills } from "../../src/utils";
|
||||
import { LogLevel } from "matrix-bot-sdk";
|
||||
import ManagementRoomOutput from "../../src/ManagementRoomOutput";
|
||||
|
||||
describe("Test: utils", function() {
|
||||
it("replaceRoomIdsWithPills correctly turns a room ID in to a pill", async function() {
|
||||
this.timeout(20000);
|
||||
|
||||
const managementRoomAlias = this.config.managementRoom;
|
||||
|
||||
const managementRoomOutput: ManagementRoomOutput = this.mjolnir.managementRoomOutput;
|
||||
await this.mjolnir.client.sendStateEvent(
|
||||
this.mjolnir.managementRoomId,
|
||||
"m.room.canonical_alias",
|
||||
@ -17,15 +13,20 @@ describe("Test: utils", function() {
|
||||
{ alias: managementRoomAlias }
|
||||
);
|
||||
|
||||
const out = await replaceRoomIdsWithPills(
|
||||
this.mjolnir,
|
||||
`it's fun here in ${this.mjolnir.managementRoomId}`,
|
||||
new Set([this.mjolnir.managementRoomId, "!myfaketestid:example.com"])
|
||||
);
|
||||
|
||||
const ourHomeserver = new UserID(await this.mjolnir.client.getUserId()).domain;
|
||||
const message: any = await new Promise(async resolve => {
|
||||
this.mjolnir.client.on('room.message', (roomId, event) => {
|
||||
if (roomId === this.mjolnir.managementRoomId) {
|
||||
if (event.content?.body?.startsWith("it's")) {
|
||||
resolve(event);
|
||||
}
|
||||
}
|
||||
})
|
||||
await managementRoomOutput.logMessage(LogLevel.INFO, 'replaceRoomIdsWithPills test',
|
||||
`it's fun here in ${this.mjolnir.managementRoomId}`,
|
||||
[this.mjolnir.managementRoomId, "!myfaketestid:example.com"]);
|
||||
});
|
||||
assert.equal(
|
||||
out.formatted_body,
|
||||
message.content.formatted_body,
|
||||
`it's fun here in <a href="https://matrix.to/#/${managementRoomAlias}">${managementRoomAlias}</a>`
|
||||
);
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user