Very much a WIP

This is a rough attempt to take the previous changeset and make it
pass type-check. There are no good reasons to believe that it will
work just yet.
This commit is contained in:
David Teller 2022-08-31 15:42:10 +02:00
parent 927f2bd70f
commit 42969f602c
29 changed files with 577 additions and 549 deletions

View File

@ -16,8 +16,6 @@ limitations under the License.
import { extractRequestError, LogLevel, LogService, MatrixClient, MessageType, Permalinks, TextualMessageEventContent, UserID } from "matrix-bot-sdk";
import { IConfig } from "./config";
import ErrorCache from "./ErrorCache";
import { RoomUpdateError } from "./models/RoomUpdateError";
import { htmlEscape } from "./utils";
const levelToFn = {
@ -102,7 +100,7 @@ export default class ManagementRoomOutput {
format: "org.matrix.custom.html",
};
if (!isRecursive) {
evContent = await this.replaceRoomIdsWithPills(this, clientMessage, new Set(roomIds), "m.notice");
evContent = await this.replaceRoomIdsWithPills(clientMessage, new Set(roomIds), "m.notice");
}
await client.sendMessage(this.managementRoomId, evContent);

View File

@ -20,37 +20,24 @@ import {
LogLevel,
LogService,
MatrixClient,
MatrixGlob,
MembershipEvent,
Permalinks,
UserID,
TextualMessageEventContent
} from "matrix-bot-sdk";
import { ALL_RULE_TYPES as ALL_BAN_LIST_RULE_TYPES, RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/ListRule";
import { applyServerAcls } from "./actions/ApplyAcl";
import { RoomUpdateError } from "./models/RoomUpdateError";
import { ALL_RULE_TYPES as ALL_BAN_LIST_RULE_TYPES } from "./models/ListRule";
import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler";
import { applyUserBans } from "./actions/ApplyBan";
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
import { Protection } from "./protections/IProtection";
import { PROTECTIONS } from "./protections/protections";
import { ConsequenceType, Consequence } from "./protections/consequence";
import { ProtectionSettingValidationError } from "./protections/ProtectionSettings";
import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue";
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
import { htmlEscape } from "./utils";
import { ReportManager } from "./report/ReportManager";
import { ReportPoller } from "./report/ReportPoller";
import { WebAPIs } from "./webapis/WebAPIs";
import RuleServer from "./models/RuleServer";
import { RoomMemberManager } from "./RoomMembers";
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
import { ThrottlingQueue } from "./queues/ThrottlingQueue";
import { IConfig } from "./config";
import PolicyList, { ListRuleChange } from "./models/PolicyList";
import PolicyList from "./models/PolicyList";
import { ProtectedRooms } from "./ProtectedRooms";
import ManagementRoomOutput from "./ManagementRoom";
import { ProtectionManager } from "./protections/protections";
export const STATE_NOT_STARTED = "not_started";
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
@ -58,10 +45,8 @@ export const STATE_SYNCING = "syncing";
export const STATE_RUNNING = "running";
const WATCHED_LISTS_EVENT_TYPE = "org.matrix.mjolnir.watched_lists";
const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections";
const PROTECTED_ROOMS_EVENT_TYPE = "org.matrix.mjolnir.protected_rooms";
const WARN_UNPROTECTED_ROOM_EVENT_PREFIX = "org.matrix.mjolnir.unprotected_room_warning.for.";
const CONSEQUENCE_EVENT_DATA = "org.matrix.mjolnir.consequence";
/**
* Synapse will tell us where we last got to on polling reports, so we need
* to store that for pagination on further polls
@ -72,8 +57,6 @@ export class Mjolnir {
private displayName: string;
private localpart: string;
private currentState: string = STATE_NOT_STARTED;
public readonly roomJoins: RoomMemberManager;
public protections = new Map<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
@ -83,19 +66,18 @@ export class Mjolnir {
* Every room that we are joined to except the management room. Used to implement `config.protectAllJoinedRooms`.
*/
private protectedJoinedRoomIds: string[] = [];
private protectedRoomsTracker: ProtectedRooms;
/**
* These are rooms that were explicitly said to be protected either in the config, or by what is present in the account data for `org.matrix.mjolnir.protected_rooms`.
*/
private explicitlyProtectedRoomIds: string[] = [];
private unprotectedWatchedListRooms: string[] = [];
public readonly protectedRoomsTracker: ProtectedRooms; // FIXME: Construct
private webapis: WebAPIs;
public taskQueue: ThrottlingQueue;
private managementRoom: ManagementRoomOutput;
public readonly managementRoom: ManagementRoomOutput;
/*
* Config-enabled polling of reports in Synapse, so Mjolnir can react to reports
*/
private reportPoller?: ReportPoller;
public readonly protectionManager: ProtectionManager;
public readonly reportManager: ReportManager;
/**
* Adds a listener to the client that will automatically accept invitations.
* @param {MatrixClient} client
@ -131,7 +113,7 @@ export class Mjolnir {
const spaceUserIds = await client.getJoinedRoomMembers(spaceId)
.catch(async e => {
if (e.body?.errcode === "M_FORBIDDEN") {
await mjolnir.logMessage(LogLevel.ERROR, 'Mjolnir', `Mjolnir is not in the space configured for acceptInvitesFromSpace, did you invite it?`);
await mjolnir.managementRoom.logMessage(LogLevel.ERROR, 'Mjolnir', `Mjolnir is not in the space configured for acceptInvitesFromSpace, did you invite it?`);
await client.joinRoom(spaceId);
return await client.getJoinedRoomMembers(spaceId);
} else {
@ -177,7 +159,7 @@ export class Mjolnir {
const ruleServer = config.web.ruleServer ? new RuleServer() : null;
const mjolnir = new Mjolnir(client, managementRoomId, config, protectedRooms, policyLists, ruleServer);
await mjolnir.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status.");
await mjolnir.managementRoom.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status.");
Mjolnir.addJoinOnInviteListener(mjolnir, client, config);
return mjolnir;
}
@ -190,17 +172,11 @@ export class Mjolnir {
* All the rooms that Mjolnir is protecting and their permalinks.
* If `config.protectAllJoinedRooms` is specified, then `protectedRooms` will be all joined rooms except watched banlists that we can't protect (because they aren't curated by us).
*/
public readonly protectedRooms: { [roomId: string]: string },
protectedRooms: { [roomId: string]: string },
private policyLists: PolicyList[],
// Combines the rules from ban lists so they can be served to a homeserver module or another consumer.
public readonly ruleServer: RuleServer | null,
) {
this.explicitlyProtectedRoomIds = Object.keys(this.protectedRooms);
for (const reason of this.config.automaticallyRedactForReasons) {
this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase()));
}
// Setup bot.
client.on("room.event", this.handleEvent.bind(this));
@ -260,15 +236,14 @@ export class Mjolnir {
// Setup Web APIs
console.log("Creating Web APIs");
const reportManager = new ReportManager(this);
reportManager.on("report.new", this.handleReport.bind(this));
this.webapis = new WebAPIs(reportManager, this.config, this.ruleServer);
this.reportManager = new ReportManager(this);
this.webapis = new WebAPIs(this.reportManager, this.config, this.ruleServer);
if (config.pollReports) {
this.reportPoller = new ReportPoller(this, reportManager);
this.reportPoller = new ReportPoller(this, this.reportManager);
}
// Setup join/leave listener
this.roomJoins = new RoomMemberManager(this.client);
this.taskQueue = new ThrottlingQueue(this, config.backgroundDelayMS);
this.protectionManager = new ProtectionManager(this);
}
public get lists(): PolicyList[] {
@ -279,10 +254,6 @@ export class Mjolnir {
return this.currentState;
}
public get enabledProtections(): Protection[] {
return [...this.protections.values()].filter(p => p.enabled);
}
/**
* Returns the handler to flag a user for redaction, removing any future messages that they send.
* Typically this is used by the flooding or image protection on users that have not been banned from a list yet.
@ -323,31 +294,19 @@ export class Mjolnir {
await this.managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir@startup", "Loading protected rooms...");
await this.resyncJoinedRooms(false);
try {
const data: { rooms?: string[] } | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
if (data && data['rooms']) {
for (const roomId of data['rooms']) {
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
this.explicitlyProtectedRoomIds.push(roomId);
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
}
}
} catch (e) {
LogService.warn("Mjolnir", extractRequestError(e));
}
await this.protectedRoomsTracker.start();
await this.buildWatchedPolicyLists();
this.applyUnprotectedRooms();
if (this.config.verifyPermissionsOnStartup) {
await this.managementRoom.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions...");
await this.verifyPermissions(this.config.verboseLogging);
await this.protectedRoomsTracker.verifyPermissions(this.config.verboseLogging);
}
this.currentState = STATE_SYNCING;
if (this.config.syncOnStartup) {
await this.managementRoom.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists...");
await this.syncLists(this.config.verboseLogging);
await this.registerProtections();
await this.protectedRoomsTracker.syncLists(this.config.verboseLogging);
await this.protectionManager.start();
}
this.currentState = STATE_RUNNING;
@ -376,42 +335,6 @@ export class Mjolnir {
this.reportPoller?.stop();
}
public async addProtectedRoom(roomId: string) {
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
this.roomJoins.addRoom(roomId);
const unprotectedIdx = this.unprotectedWatchedListRooms.indexOf(roomId);
if (unprotectedIdx >= 0) this.unprotectedWatchedListRooms.splice(unprotectedIdx, 1);
this.explicitlyProtectedRoomIds.push(roomId);
let additionalProtectedRooms: { rooms?: string[] } | null = null;
try {
additionalProtectedRooms = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
} catch (e) {
LogService.warn("Mjolnir", extractRequestError(e));
}
const rooms = (additionalProtectedRooms?.rooms ?? []);
rooms.push(roomId);
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: rooms });
}
public async removeProtectedRoom(roomId: string) {
delete this.protectedRooms[roomId];
this.roomJoins.removeRoom(roomId);
const idx = this.explicitlyProtectedRoomIds.indexOf(roomId);
if (idx >= 0) this.explicitlyProtectedRoomIds.splice(idx, 1);
let additionalProtectedRooms: { rooms?: string[] } | null = null;
try {
additionalProtectedRooms = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
} catch (e) {
LogService.warn("Mjolnir", extractRequestError(e));
}
additionalProtectedRooms = { rooms: additionalProtectedRooms?.rooms?.filter(r => r !== roomId) ?? [] };
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms);
}
// need to brewritten to add/remove from a ProtectedRooms instance.
private async resyncJoinedRooms(withSync = true) {
// this is really terrible!
@ -420,192 +343,29 @@ export class Mjolnir {
if (!this.config.protectAllJoinedRooms) return;
const joinedRoomIds = (await this.client.getJoinedRooms())
.filter(r => r !== this.managementRoomId && !this.unprotectedWatchedListRooms.includes(r));
.filter(r => r !== this.managementRoomId && !this.protectedRoomsTracker.unprotectedWatchedListRooms.includes(r));
const oldRoomIdsSet = new Set(this.protectedJoinedRoomIds);
const joinedRoomIdsSet = new Set(joinedRoomIds);
// find every room that we have left (since last time)
for (const roomId of oldRoomIdsSet.keys()) {
if (!joinedRoomIdsSet.has(roomId)) {
// Then we have left this room.
delete this.protectedRooms[roomId];
this.protectedRoomsTracker.removeProtectedRoom(roomId);
this.roomJoins.removeRoom(roomId);
}
}
// find every room that we have joined (since last time).
for (const roomId of joinedRoomIdsSet.keys()) {
if (!oldRoomIdsSet.has(roomId)) {
// Then we have joined this room
this.roomJoins.addRoom(roomId);
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
await this.protectedRoomsTracker.addProtectedRoom(roomId);
}
}
this.applyUnprotectedRooms();
if (withSync) {
await this.syncLists(this.config.verboseLogging);
await this.protectedRoomsTracker.syncLists(this.config.verboseLogging);
}
}
/*
* Take all the builtin protections, register them to set their enabled (or not) state and
* update their settings with any saved non-default values
*/
private async registerProtections() {
for (const protection of PROTECTIONS) {
try {
await this.registerProtection(protection);
} catch (e) {
LogService.warn("Mjolnir", extractRequestError(e));
}
}
}
/*
* Make a list of the names of enabled protections and save them in a state event
*/
private async saveEnabledProtections() {
const protections = this.enabledProtections.map(p => p.name);
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: protections });
}
/*
* Enable a protection by name and persist its enable state in to a state event
*
* @param name The name of the protection whose settings we're enabling
*/
public async enableProtection(name: string) {
const protection = this.protections.get(name);
if (protection !== undefined) {
protection.enabled = true;
await this.saveEnabledProtections();
}
}
/*
* Disable a protection by name and remove it from the persistent list of enabled protections
*
* @param name The name of the protection whose settings we're disabling
*/
public async disableProtection(name: string) {
const protection = this.protections.get(name);
if (protection !== undefined) {
protection.enabled = false;
await this.saveEnabledProtections();
}
}
/*
* Read org.matrix.mjolnir.setting state event, find any saved settings for
* the requested protectionName, then iterate and validate against their parser
* counterparts in Protection.settings and return those which validate
*
* @param protectionName The name of the protection whose settings we're reading
* @returns Every saved setting for this protectionName that has a valid value
*/
public async getProtectionSettings(protectionName: string): Promise<{ [setting: string]: any }> {
let savedSettings: { [setting: string]: any } = {}
try {
savedSettings = await this.client.getRoomStateEvent(
this.managementRoomId, 'org.matrix.mjolnir.setting', protectionName
);
} catch {
// setting does not exist, return empty object
return {};
}
const settingDefinitions = this.protections.get(protectionName)?.settings ?? {};
const validatedSettings: { [setting: string]: any } = {}
for (let [key, value] of Object.entries(savedSettings)) {
if (
// is this a setting name with a known parser?
key in settingDefinitions
// is the datatype of this setting's value what we expect?
&& typeof (settingDefinitions[key].value) === typeof (value)
// is this setting's value valid for the setting?
&& settingDefinitions[key].validate(value)
) {
validatedSettings[key] = value;
} else {
await this.managementRoom.logMessage(
LogLevel.WARN,
"getProtectionSetting",
`Tried to read ${protectionName}.${key} and got invalid value ${value}`
);
}
}
return validatedSettings;
}
/*
* Takes an object of settings we want to change and what their values should be,
* check that their values are valid, combine them with current saved settings,
* then save the amalgamation to a state event
*
* @param protectionName Which protection these settings belong to
* @param changedSettings The settings to change and their values
*/
public async setProtectionSettings(protectionName: string, changedSettings: { [setting: string]: any }): Promise<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.
@ -615,22 +375,12 @@ 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);
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);
@ -673,13 +423,12 @@ export class Mjolnir {
public async warnAboutUnprotectedPolicyListRoom(roomId: string) {
if (!this.config.protectAllJoinedRooms) return; // doesn't matter
if (this.explicitlyProtectedRoomIds.includes(roomId)) return; // explicitly protected
if (this.protectedRoomsTracker.explicitlyProtectedRoomIds.includes(roomId)) return; // explicitly protected
const createEvent = new CreateEvent(await this.client.getRoomStateEvent(roomId, "m.room.create", ""));
if (createEvent.creator === await this.client.getUserId()) return; // we created it
if (!this.unprotectedWatchedListRooms.includes(roomId)) this.unprotectedWatchedListRooms.push(roomId);
this.applyUnprotectedRooms();
this.protectedRoomsTracker.addUnprotectedWatchedListRoom(roomId);
try {
const accountData: { warned: boolean } | null = await this.client.getAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId);
@ -692,13 +441,6 @@ export class Mjolnir {
await this.client.setAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId, { warned: true });
}
private applyUnprotectedRooms() {
for (const roomId of this.unprotectedWatchedListRooms) {
delete this.protectedRooms[roomId];
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
}
}
private async buildWatchedPolicyLists() {
this.policyLists = [];
const joinedRooms = await this.client.getJoinedRooms();
@ -724,44 +466,6 @@ export class Mjolnir {
}
}
private requiredProtectionPermissions(): Set<string> {
return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat())
}
private async handleConsequence(protection: Protection, roomId: string, eventId: string, sender: string, consequence: Consequence) {
switch (consequence.type) {
case ConsequenceType.alert:
break;
case ConsequenceType.redact:
await this.client.redactEvent(roomId, eventId, "abuse detected");
break;
case ConsequenceType.ban:
await this.client.banUser(sender, roomId, "abuse detected");
break;
}
let message = `protection ${protection.name} enacting ${ConsequenceType[consequence.type]}`
+ ` against ${htmlEscape(sender)}`
+ ` in ${htmlEscape(roomId)}`;
if (consequence.reason !== undefined) {
// even though internally-sourced, there's no promise that `consequence.reason`
// will never have user-supplied information, so escape it
message += ` (reason: ${htmlEscape(consequence.reason)})`;
}
await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice",
body: message,
[CONSEQUENCE_EVENT_DATA]: {
who: sender,
room: roomId,
type: ConsequenceType[consequence.type]
}
});
}
private async handleEvent(roomId: string, event: any) {
// Check for UISI errors
if (roomId === this.managementRoomId) {
@ -783,35 +487,6 @@ export class Mjolnir {
policyList.updateForEvent(event)
}
}
if (roomId in this.protectedRooms) {
if (event['sender'] === await this.client.getUserId()) return; // Ignore ourselves
// Iterate all the enabled protections
for (const protection of this.enabledProtections) {
let consequence: Consequence | undefined = undefined;
try {
consequence = await protection.handleEvent(this, roomId, event);
} catch (e) {
const eventPermalink = Permalinks.forEvent(roomId, event['event_id']);
LogService.error("Mjolnir", "Error handling protection: " + protection.name);
LogService.error("Mjolnir", "Failed event: " + eventPermalink);
LogService.error("Mjolnir", extractRequestError(e));
await this.client.sendNotice(this.managementRoomId, "There was an error processing an event through a protection - see log for details. Event: " + eventPermalink);
continue;
}
if (consequence !== undefined) {
await this.handleConsequence(protection, roomId, event["event_id"], event["sender"], consequence);
}
}
// Run the event handlers - we always run this after protections so that the protections
// can flag the event for redaction.
await this.unlistedUserRedactionHandler.handleEvent(roomId, event, this);
}
}
public async isSynapseAdmin(): Promise<boolean> {
@ -856,10 +531,4 @@ export class Mjolnir {
return extractRequestError(e);
}
}
private async handleReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) {
for (const protection of this.enabledProtections) {
await protection.handleReport(this, roomId, reporterId, event, reason);
}
}
}

View File

@ -22,10 +22,14 @@ import { RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/ListRule";
import PolicyList, { ListRuleChange } from "./models/PolicyList";
import { RoomUpdateError } from "./models/RoomUpdateError";
import { ServerAcl } from "./models/ServerAcl";
import { ProtectionManager } from "./protections/protections";
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
import { RoomMemberManager } from "./RoomMembers";
import { htmlEscape } from "./utils";
const PROTECTED_ROOMS_EVENT_TYPE = "org.matrix.mjolnir.protected_rooms";
/**
* When you consider spaces https://github.com/matrix-org/mjolnir/issues/283
* rather than indexing rooms via some collection, you instead have rooms
@ -37,9 +41,6 @@ import { htmlEscape } from "./utils";
* as in future we might want to borrow this class to represent a space.
*/
export class ProtectedRooms {
private protectedRooms = new Set</* room id */string>();
private policyLists: PolicyList[];
private protectedRoomActivityTracker: ProtectedRoomActivityTracker;
@ -54,6 +55,31 @@ export class ProtectedRooms {
private automaticRedactionReasons: MatrixGlob[] = [];
/**
* A list of rooms that we watch and protect.
*/
private readonly _protectedRooms = new Map</* room id */string, /* perma url */ string>();
public get protectedRooms(): Iterable<string> {
return this._protectedRooms.keys();
}
/**
* A list of rooms that we watch but do not protect.
*/
private _unprotectedWatchedListRooms: string[] = [];
public get unprotectedWatchedListRooms(): ReadonlyArray<string> {
return this._unprotectedWatchedListRooms;
}
/**
* These are rooms that were explicitly said to be protected either in the config, or by what is present in the account data for `org.matrix.mjolnir.protected_rooms`.
*/
private _explicitlyProtectedRoomIds: string[] = [];
public get explicitlyProtectedRoomIds(): ReadonlyArray<string> {
return this._explicitlyProtectedRoomIds;
}
public readonly roomJoins: RoomMemberManager;
/**
* Used to provide mutual exclusion when synchronizing rooms with the state of a policy list.
* This is because requests operating with rules from an older version of the list that are slow
@ -69,10 +95,37 @@ export class ProtectedRooms {
private readonly clientUserId: string,
private readonly managementRoomId: string,
private readonly managementRoom: ManagementRoomOutput,
private readonly protections: ProtectionManager,
private readonly config: IConfig,
protectedRooms: string[],
) {
this._explicitlyProtectedRoomIds = protectedRooms;
for (const reason of this.config.automaticallyRedactForReasons) {
this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase()));
}
// Setup room activity watcher
this.protectedRoomActivityTracker = new ProtectedRoomActivityTracker(client);
// Setup join/leave listener
this.roomJoins = new RoomMemberManager(this.client);
}
public async start() {
try {
const data: { rooms?: string[] } | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
if (data && data['rooms']) {
for (const roomId of data['rooms']) {
this.addProtectedRoom(roomId);
}
}
} catch (e) {
LogService.warn("ProtectedRooms", extractRequestError(e));
}
for (const roomId of this._unprotectedWatchedListRooms) {
this.removeProtectedRoom(roomId);
}
}
public queueRedactUserMessagesIn(userId: string, roomId: string) {
@ -110,7 +163,7 @@ export class ProtectedRooms {
// power levels were updated - recheck permissions
this.errorCache.resetError(roomId, ERROR_KIND_PERMISSION);
await this.managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir", `Power levels changed in ${roomId} - checking permissions...`, roomId);
const errors = await this.verifyPermissionsIn(roomId);
const errors = await this.protections.verifyPermissionsIn(roomId);
const hadErrors = await this.printActionResult(errors);
if (!hadErrors) {
await this.managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir", `All permissions look OK.`);
@ -161,19 +214,53 @@ export class ProtectedRooms {
}
public async addProtectedRoom(roomId: string): Promise<void> {
if (this.protectedRooms.has(roomId)) {
if (this._protectedRooms.has(roomId)) {
// we need to protect ourselves form syncing all the lists unnecessarily
// as Mjolnir does call this method repeatedly.
return;
}
this.protectedRooms.add(roomId);
this._protectedRooms.set(roomId, Permalinks.forRoom(roomId));
this._explicitlyProtectedRoomIds.push(roomId);
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
this.roomJoins.addRoom(roomId);
const unprotectedIdx = this._unprotectedWatchedListRooms.indexOf(roomId);
if (unprotectedIdx >= 0) this._unprotectedWatchedListRooms.splice(unprotectedIdx, 1);
this._explicitlyProtectedRoomIds.push(roomId);
let additionalProtectedRooms: { rooms?: string[] } | null = null;
try {
additionalProtectedRooms = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
} catch (e) {
LogService.warn("Mjolnir", extractRequestError(e));
}
const rooms = (additionalProtectedRooms?.rooms ?? []);
rooms.push(roomId);
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: rooms });
await this.syncLists(this.config.verboseLogging);
}
public removeProtectedRoom(roomId: string): void {
public async removeProtectedRoom(roomId: string) {
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
this.protectedRooms.delete(roomId);
this._protectedRooms.delete(roomId);
this.roomJoins.removeRoom(roomId);
const idx = this._explicitlyProtectedRoomIds.indexOf(roomId);
if (idx >= 0) this._explicitlyProtectedRoomIds.splice(idx, 1);
let additionalProtectedRooms: { rooms?: string[] } | null = null;
try {
additionalProtectedRooms = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
} catch (e) {
LogService.warn("Mjolnir", extractRequestError(e));
}
additionalProtectedRooms = { rooms: additionalProtectedRooms?.rooms?.filter(r => r !== roomId) ?? [] };
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms);
}
public isProtectedRoom(roomId: string): boolean {
return this._protectedRooms.has(roomId);
}
/**
@ -182,7 +269,7 @@ export class ProtectedRooms {
* @param policyList The `PolicyList` which we will check for changes and apply them to all protected rooms.
* @returns When all of the protected rooms have been updated.
*/
private async syncWithPolicyList(policyList: PolicyList): Promise<void> {
public async syncWithPolicyList(policyList: PolicyList): Promise<void> {
// this bit can move away into a listener.
const changes = await policyList.updateList();
@ -445,8 +532,8 @@ export class ProtectedRooms {
public async verifyPermissions(verbose = true, printRegardless = false) {
const errors: RoomUpdateError[] = [];
for (const roomId of Object.keys(this.protectedRooms)) {
errors.push(...(await this.verifyPermissionsIn(roomId)));
for (const roomId of this._protectedRooms.keys()) {
errors.push(...(await this.protections.verifyPermissionsIn(roomId)));
}
const hadErrors = await this.printActionResult(errors, "Permission errors in protected rooms:", printRegardless);
@ -462,90 +549,11 @@ export class ProtectedRooms {
}
}
private async verifyPermissionsIn(roomId: string): Promise<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,
});
public addUnprotectedWatchedListRoom(roomId: string) {
if (this._unprotectedWatchedListRooms.includes(roomId)) {
return;
}
return errors;
this._unprotectedWatchedListRooms.push(roomId);
this.removeProtectedRoom(roomId);
}
}

View File

@ -20,19 +20,19 @@ import { extractRequestError, LogLevel, LogService } from "matrix-bot-sdk";
// !mjolnir rooms add <room alias/ID>
export async function execAddProtectedRoom(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const protectedRoomId = await mjolnir.client.joinRoom(parts[3]);
await mjolnir.addProtectedRoom(protectedRoomId);
await mjolnir.protectedRoomsTracker.addProtectedRoom(protectedRoomId);
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
}
// !mjolnir rooms remove <room alias/ID>
export async function execRemoveProtectedRoom(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const protectedRoomId = await mjolnir.client.resolveRoom(parts[3]);
await mjolnir.removeProtectedRoom(protectedRoomId);
await mjolnir.protectedRoomsTracker.removeProtectedRoom(protectedRoomId);
try {
await mjolnir.client.leaveRoom(protectedRoomId);
} catch (e) {
LogService.warn("AddRemoveProtectedRoomsCommand", extractRequestError(e));
await mjolnir.logMessage(LogLevel.WARN, "AddRemoveProtectedRoomsCommand", `Failed to leave ${protectedRoomId} - the room is no longer being protected, but the bot could not leave`, protectedRoomId);
await mjolnir.managementRoom.logMessage(LogLevel.WARN, "AddRemoveProtectedRoomsCommand", `Failed to leave ${protectedRoomId} - the room is no longer being protected, but the bot could not leave`, protectedRoomId);
}
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
}

View File

@ -22,7 +22,7 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln
let force = false;
const glob = parts[2];
let rooms = [...Object.keys(mjolnir.protectedRooms)];
let rooms = [...Object.keys(mjolnir.protectedRoomsTracker.protectedRooms)];
if (parts[parts.length - 1] === "--force") {
force = true;
@ -57,7 +57,7 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln
const victim = member.membershipFor;
if (kickRule.test(victim)) {
await mjolnir.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId);
await mjolnir.managementRoom.logMessage(LogLevel.DEBUG, "KickCommand", `Removing ${victim} in ${protectedRoomId}`, protectedRoomId);
if (!mjolnir.config.noop) {
try {
@ -65,10 +65,10 @@ export async function execKickCommand(roomId: string, event: any, mjolnir: Mjoln
return mjolnir.client.kickUser(victim, protectedRoomId, reason);
});
} catch (e) {
await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`);
await mjolnir.managementRoom.logMessage(LogLevel.WARN, "KickCommand", `An error happened while trying to kick ${victim}: ${e}`);
}
} else {
await mjolnir.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${victim} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId);
await mjolnir.managementRoom.logMessage(LogLevel.WARN, "KickCommand", `Tried to kick ${victim} in ${protectedRoomId} but the bot is running in no-op mode.`, protectedRoomId);
}
}
}

View File

@ -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.protectedRooms];
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`;
}

View File

@ -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);
}

View File

@ -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`;

View File

@ -45,8 +45,8 @@ export async function execRedactCommand(roomId: string, event: any, mjolnir: Mjo
return;
}
const targetRoomIds = roomAlias ? [roomAlias] : Object.keys(mjolnir.protectedRooms);
await redactUserMessagesIn(mjolnir, userId, targetRoomIds, limit);
const targetRoomIds = roomAlias ? [roomAlias] : [...mjolnir.protectedRoomsTracker.protectedRooms];
await redactUserMessagesIn(mjolnir.client, mjolnir.managementRoom, userId, targetRoomIds, limit);
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
await mjolnir.client.redactEvent(roomId, processingReactionId, 'done processing');

View File

@ -23,14 +23,14 @@ export async function execSetPowerLevelCommand(roomId: string, event: any, mjoln
const level = Math.round(Number(parts[3]));
const inRoom = parts[4];
let targetRooms = inRoom ? [await mjolnir.client.resolveRoom(inRoom)] : Object.keys(mjolnir.protectedRooms);
let targetRooms = inRoom ? [await mjolnir.client.resolveRoom(inRoom)] : [...mjolnir.protectedRoomsTracker.protectedRooms];
for (const targetRoomId of targetRooms) {
try {
await mjolnir.client.setUserPowerLevel(victim, targetRoomId, level);
} catch (e) {
const message = e.message || (e.body ? e.body.error : '<no message>');
await mjolnir.logMessage(LogLevel.ERROR, "SetPowerLevelCommand", `Failed to set power level of ${victim} to ${level} in ${targetRoomId}: ${message}`, targetRoomId);
await mjolnir.managementRoom.logMessage(LogLevel.ERROR, "SetPowerLevelCommand", `Failed to set power level of ${victim} to ${level} in ${targetRoomId}: ${message}`, targetRoomId);
LogService.error("SetPowerLevelCommand", extractRequestError(e));
}
}

View File

@ -100,7 +100,7 @@ export async function execSinceCommand(destinationRoomId: string, event: any, mj
let result = await execSinceCommandAux(destinationRoomId, event, mjolnir, tokens);
if ("error" in result) {
mjolnir.client.unstableApis.addReactionToEvent(destinationRoomId, event['event_id'], '❌');
mjolnir.logMessage(LogLevel.WARN, "SinceCommand", result.error);
mjolnir.managementRoom.logMessage(LogLevel.WARN, "SinceCommand", result.error);
const reply = RichReply.createFor(destinationRoomId, event, result.error, htmlEscape(result.error));
reply["msgtype"] = "m.notice";
/* no need to await */ mjolnir.client.sendMessage(destinationRoomId, reply);
@ -185,6 +185,7 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni
// Now list affected rooms.
const rooms: Set</* room id */string> = new Set();
let reasonParts: string[] | undefined;
const protectedRooms = new Set(mjolnir.protectedRoomsTracker.protectedRooms);
for (let token of optionalTokens) {
const maybeArg = getTokenAsString(reasonParts ? "[reason]" : "[room]", token);
if ("error" in maybeArg) {
@ -194,14 +195,14 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni
if (!reasonParts) {
// If we haven't reached the reason yet, attempt to use `maybeRoom` as a room.
if (maybeRoom === "*") {
for (let roomId of Object.keys(mjolnir.protectedRooms)) {
for (let roomId of mjolnir.protectedRoomsTracker.protectedRooms) {
rooms.add(roomId);
}
continue;
} else if (maybeRoom.startsWith("#") || maybeRoom.startsWith("!")) {
const roomId = await mjolnir.client.resolveRoom(maybeRoom);
if (!(roomId in mjolnir.protectedRooms)) {
return mjolnir.logMessage(LogLevel.WARN, "SinceCommand", `This room is not protected: ${htmlEscape(roomId)}.`);
if (!protectedRooms.has(roomId)) {
return mjolnir.managementRoom.logMessage(LogLevel.WARN, "SinceCommand", `This room is not protected: ${htmlEscape(roomId)}.`);
}
rooms.add(roomId);
continue;
@ -225,7 +226,7 @@ async function execSinceCommandAux(destinationRoomId: string, event: any, mjolni
for (let targetRoomId of rooms) {
let {html, text} = await (async () => {
let results: Summary = { succeeded: [], failed: []};
const recentJoins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries);
const recentJoins = mjolnir.protectedRoomsTracker.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries);
switch (action) {
case Action.Show: {

View File

@ -67,8 +67,9 @@ async function showMjolnirStatus(roomId: string, event: any, mjolnir: Mjolnir) {
break;
}
html += `<b>Protected rooms: </b> ${Object.keys(mjolnir.protectedRooms).length}<br/>`;
text += `Protected rooms: ${Object.keys(mjolnir.protectedRooms).length}\n`;
const protectedRooms = [...mjolnir.protectedRoomsTracker.protectedRooms];
html += `<b>Protected rooms: </b> ${protectedRooms.length}<br/>`;
text += `Protected rooms: ${protectedRooms.length}\n`;
// Append list information
html += "<b>Subscribed ban lists:</b><br><ul>";
@ -91,7 +92,7 @@ async function showMjolnirStatus(roomId: string, event: any, mjolnir: Mjolnir) {
async function showProtectionStatus(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const protectionName = parts[0];
const protection = mjolnir.getProtection(protectionName);
const protection = mjolnir.protectionManager.getProtection(protectionName);
let text;
let html;
if (!protection) {
@ -156,7 +157,7 @@ async function showJoinsStatus(destinationRoomId: string, event: any, mjolnir: M
text: `Cannot resolve room \`${targetRoomAliasOrId}\`.`
}
}
const joins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries);
const joins = mjolnir.protectedRoomsTracker.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries);
const htmlFragments = [];
const textFragments = [];
for (let join of joins) {

View File

@ -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);
}

View File

@ -138,21 +138,21 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol
if (USER_RULE_TYPES.includes(bits.ruleType!) && bits.reason === 'true') {
const rule = new MatrixGlob(bits.entity);
await mjolnir.logMessage(LogLevel.INFO, "UnbanBanCommand", "Unbanning users that match glob: " + bits.entity);
await mjolnir.managementRoom.logMessage(LogLevel.INFO, "UnbanBanCommand", "Unbanning users that match glob: " + bits.entity);
let unbannedSomeone = false;
for (const protectedRoomId of Object.keys(mjolnir.protectedRooms)) {
for (const protectedRoomId of mjolnir.protectedRoomsTracker.protectedRooms) {
const members = await mjolnir.client.getRoomMembers(protectedRoomId, undefined, ['ban'], undefined);
await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Found ${members.length} banned user(s)`);
await mjolnir.managementRoom.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Found ${members.length} banned user(s)`);
for (const member of members) {
const victim = member.membershipFor;
if (member.membership !== 'ban') continue;
if (rule.test(victim)) {
await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Unbanning ${victim} in ${protectedRoomId}`, protectedRoomId);
await mjolnir.managementRoom.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Unbanning ${victim} in ${protectedRoomId}`, protectedRoomId);
if (!mjolnir.config.noop) {
await mjolnir.client.unbanUser(victim, protectedRoomId);
} else {
await mjolnir.logMessage(LogLevel.WARN, "UnbanBanCommand", `Attempted to unban ${victim} in ${protectedRoomId} but Mjolnir is running in no-op mode`, protectedRoomId);
await mjolnir.managementRoom.logMessage(LogLevel.WARN, "UnbanBanCommand", `Attempted to unban ${victim} in ${protectedRoomId} but Mjolnir is running in no-op mode`, protectedRoomId);
}
unbannedSomeone = true;
@ -161,8 +161,8 @@ export async function execUnbanCommand(roomId: string, event: any, mjolnir: Mjol
}
if (unbannedSomeone) {
await mjolnir.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Syncing lists to ensure no users were accidentally unbanned`);
await mjolnir.syncLists(mjolnir.config.verboseLogging);
await mjolnir.managementRoom.logMessage(LogLevel.DEBUG, "UnbanBanCommand", `Syncing lists to ensure no users were accidentally unbanned`);
await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
}
}

View File

@ -62,11 +62,11 @@ export class BasicFlooding extends Protection {
}
if (messageCount >= this.settings.maxPerMinute.value) {
await mjolnir.logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`, roomId);
await mjolnir.managementRoom.logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`, roomId);
if (!mjolnir.config.noop) {
await mjolnir.client.banUser(event['sender'], roomId, "spam");
} else {
await mjolnir.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
await mjolnir.managementRoom.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
if (this.recentlyBanned.includes(event['sender'])) return; // already handled (will be redacted)
@ -79,7 +79,7 @@ export class BasicFlooding extends Protection {
await mjolnir.client.redactEvent(roomId, eventId, "spam");
}
} else {
await mjolnir.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to redact messages for ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
await mjolnir.managementRoom.logMessage(LogLevel.WARN, "BasicFlooding", `Tried to redact messages for ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
// Free up some memory now that we're ready to handle it elsewhere

View File

@ -627,7 +627,7 @@ export class DetectFederationLag extends Protection {
roomInfo.latestAlertStart = now;
// Background-send message.
const stats = roomInfo.globalStats();
/* do not await */ mjolnir.logMessage(LogLevel.WARN, "FederationLag",
/* do not await */ mjolnir.managementRoom.logMessage(LogLevel.WARN, "FederationLag",
`Room ${roomId} is experiencing ${isLocalDomainOnAlert ? "LOCAL" : "federated"} lag since ${roomInfo.latestAlertStart}.\n${roomInfo.alerts} homeservers are lagging: ${[...roomInfo.serversOnAlert()].sort()} .\nRoom lag statistics: ${JSON.stringify(stats, null, 2)}.`);
// Drop a state event, for the use of potential other bots.
const warnStateEventId = await mjolnir.client.sendStateEvent(mjolnir.managementRoomId, LAG_STATE_EVENT, roomId, {
@ -642,7 +642,7 @@ export class DetectFederationLag extends Protection {
} else if (roomInfo.alerts < this.settings.numberOfLaggingFederatedHomeserversExitWarningZone.value
|| !isLocalDomainOnAlert) {
// Stop the alarm!
/* do not await */ mjolnir.logMessage(LogLevel.INFO, "FederationLag",
/* do not await */ mjolnir.managementRoom.logMessage(LogLevel.INFO, "FederationLag",
`Room ${roomId} lag has decreased to an acceptable level. Currently, ${roomInfo.alerts} homeservers are still lagging`
);
if (roomInfo.warnStateEventId) {

View File

@ -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.managementRoom.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.managementRoom.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.managementRoom.logMessage(LogLevel.WARN, "FirstMessageIsImage", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
}
}

View File

@ -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.managementRoom.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Setting ${roomId} to invite-only as more than ${this.settings.maxPer.value} users have joined over the last ${this.settings.timescaleMinutes.value} minutes (since ${this.joinBuckets[roomId].lastBucketStart})`, roomId);
if (!mjolnir.config.noop) {
await mjolnir.client.sendStateEvent(roomId, "m.room.join_rules", "", {"join_rule": "invite"})
} else {
await mjolnir.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Tried to set ${roomId} to invite-only, but Mjolnir is running in no-op mode`, roomId);
await mjolnir.managementRoom.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Tried to set ${roomId} to invite-only, but Mjolnir is running in no-op mode`, roomId);
}
}
}

View File

@ -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.managementRoom.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.managementRoom.logMessage(LogLevel.WARN, "MessageIsMedia", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
}
}

View File

@ -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.managementRoom.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.managementRoom.logMessage(LogLevel.WARN, "MessageIsVoice", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
}
}

View File

@ -91,24 +91,24 @@ 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.managementRoom.logMessage(LogLevel.ERROR, "WordList", `Could not produce a regex from the word list:\n${ex}.`)
}
}
// Perform the test
if (message && this.badWords!.test(message)) {
await mjolnir.logMessage(LogLevel.WARN, "WordList", `Banning ${event['sender']} for word list violation in ${roomId}.`);
await mjolnir.managementRoom.logMessage(LogLevel.WARN, "WordList", `Banning ${event['sender']} for word list violation in ${roomId}.`);
if (!mjolnir.config.noop) {
await mjolnir.client.banUser(event['sender'], roomId, "Word list violation");
} else {
await mjolnir.logMessage(LogLevel.WARN, "WordList", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
await mjolnir.managementRoom.logMessage(LogLevel.WARN, "WordList", `Tried to ban ${event['sender']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
// Redact the event
if (!mjolnir.config.noop) {
await mjolnir.client.redactEvent(roomId, event['event_id'], "spam");
} else {
await mjolnir.logMessage(LogLevel.WARN, "WordList", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
await mjolnir.managementRoom.logMessage(LogLevel.WARN, "WordList", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
}
}

View File

@ -23,8 +23,15 @@ 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, ConsequenceType } from "./consequence";
import { htmlEscape } from "../utils";
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
import { RoomUpdateError } from "../models/RoomUpdateError";
export const PROTECTIONS: Protection[] = [
const PROTECTIONS: Protection[] = [
new FirstMessageIsImage(),
new BasicFlooding(),
new WordList(),
@ -34,3 +41,353 @@ export const PROTECTIONS: Protection[] = [
new DetectFederationLag(),
new JoinWaveShortCircuit(),
];
const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections";
const CONSEQUENCE_EVENT_DATA = "org.matrix.mjolnir.consequence";
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.managementRoom.logMessage(LogLevel.WARN, "ProtectionManager", extractRequestError(e));
}
}
}
/*
* Given a protection object; add it to our list of protections, set whether it is enabled
* and update its settings with any saved non-default values.
*
* @param protection The protection object we want to register
*/
public async registerProtection(protection: Protection) {
this._protections.set(protection.name, protection)
let enabledProtections: { enabled: string[] } | null = null;
try {
enabledProtections = await this.mjolnir.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
} catch {
// this setting either doesn't exist, or we failed to read it (bad network?)
// TODO: retry on certain failures?
}
protection.enabled = enabledProtections?.enabled.includes(protection.name) ?? false;
const savedSettings = await this.getProtectionSettings(protection.name);
for (let [key, value] of Object.entries(savedSettings)) {
// this.getProtectionSettings() validates this data for us, so we don't need to
protection.settings[key].setValue(value);
}
}
/*
* Given a protection object; remove it from our list of protections.
*
* @param protection The protection object we want to unregister
*/
public unregisterProtection(protectionName: string) {
if (!(protectionName in this._protections)) {
throw new Error("Failed to find protection by name: " + protectionName);
}
this._protections.delete(protectionName);
}
/*
* Takes an object of settings we want to change and what their values should be,
* check that their values are valid, combine them with current saved settings,
* then save the amalgamation to a state event
*
* @param protectionName Which protection these settings belong to
* @param changedSettings The settings to change and their values
*/
public async setProtectionSettings(protectionName: string, changedSettings: { [setting: string]: any }): Promise<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.managementRoom.logMessage(
LogLevel.WARN,
"getProtectionSetting",
`Tried to read ${protectionName}.${key} and got invalid value ${value}`
);
}
}
return validatedSettings;
}
private async handleConsequence(protection: Protection, roomId: string, eventId: string, sender: string, consequence: Consequence) {
switch (consequence.type) {
case ConsequenceType.alert:
break;
case ConsequenceType.redact:
await this.mjolnir.client.redactEvent(roomId, eventId, "abuse detected");
break;
case ConsequenceType.ban:
await this.mjolnir.client.banUser(sender, roomId, "abuse detected");
break;
}
let message = `protection ${protection.name} enacting ${ConsequenceType[consequence.type]}`
+ ` against ${htmlEscape(sender)}`
+ ` in ${htmlEscape(roomId)}`;
if (consequence.reason !== undefined) {
// even though internally-sourced, there's no promise that `consequence.reason`
// will never have user-supplied information, so escape it
message += ` (reason: ${htmlEscape(consequence.reason)})`;
}
await this.mjolnir.client.sendMessage(this.mjolnir.managementRoomId, {
msgtype: "m.notice",
body: message,
[CONSEQUENCE_EVENT_DATA]: {
who: sender,
room: roomId,
type: ConsequenceType[consequence.type]
}
});
}
private async handleEvent(roomId: string, event: any) {
if (roomId in this.mjolnir.protectedRoomsTracker.protectedRooms) {
if (event['sender'] === await this.mjolnir.client.getUserId()) return; // Ignore ourselves
// Iterate all the enabled protections
for (const protection of this.enabledProtections) {
let consequence: Consequence | undefined = undefined;
try {
consequence = await protection.handleEvent(this.mjolnir, roomId, event);
} catch (e) {
const eventPermalink = Permalinks.forEvent(roomId, event['event_id']);
LogService.error("ProtectionManager", "Error handling protection: " + protection.name);
LogService.error("ProtectionManager", "Failed event: " + eventPermalink);
LogService.error("ProtectionManager", extractRequestError(e));
await this.mjolnir.client.sendNotice(this.mjolnir.managementRoomId, "There was an error processing an event through a protection - see log for details. Event: " + eventPermalink);
continue;
}
if (consequence !== undefined) {
await this.handleConsequence(protection, roomId, event["event_id"], event["sender"], consequence);
}
}
// Run the event handlers - we always run this after protections so that the protections
// can flag the event for redaction.
await this.mjolnir.unlistedUserRedactionHandler.handleEvent(roomId, event, this.mjolnir); // FIXME: That's rather spaghetti
}
}
private requiredProtectionPermissions(): Set<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;
}
// FIXME: Hook up
private async handleReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) {
for (const protection of this.enabledProtections) {
await protection.handleReport(this.mjolnir, roomId, reporterId, event, reason);
}
}
}

View File

@ -17,7 +17,6 @@ import { LogLevel, MatrixClient } from "matrix-bot-sdk"
import { ERROR_KIND_FATAL } from "../ErrorCache";
import { RoomUpdateError } from "../models/RoomUpdateError";
import { redactUserMessagesIn } from "../utils";
import { Mjolnir } from "../Mjolnir";
import ManagementRoomOutput from "../ManagementRoom";
export interface QueuedRedaction {

View File

@ -178,7 +178,7 @@ export class ThrottlingQueue {
try {
await task();
} catch (ex) {
await this.mjolnir.logMessage(
await this.mjolnir.managementRoom.logMessage(
LogLevel.WARN,
'Error while executing task',
extractRequestError(ex)

View File

@ -45,10 +45,10 @@ export class UnlistedUserRedactionQueue {
if (!mjolnir.config.noop) {
await mjolnir.client.redactEvent(roomId, event['event_id']);
} else {
await mjolnir.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Tried to redact ${permalink} but Mjolnir is running in no-op mode`);
await mjolnir.managementRoom.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Tried to redact ${permalink} but Mjolnir is running in no-op mode`);
}
} catch (e) {
mjolnir.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Unable to redact message: ${permalink}`);
mjolnir.managementRoom.logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Unable to redact message: ${permalink}`);
LogService.warn("AutomaticRedactionQueue", extractRequestError(e));
}
}

View File

@ -75,13 +75,13 @@ export class ReportPoller {
}
);
} catch (ex) {
await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to poll events: ${ex}`);
await this.mjolnir.managementRoom.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to poll events: ${ex}`);
return;
}
const response = response_!;
for (let report of response.event_reports) {
if (!(report.room_id in this.mjolnir.protectedRooms)) {
if (!this.mjolnir.protectedRoomsTracker.isProtectedRoom(report.room_id)) {
continue;
}
@ -92,7 +92,7 @@ export class ReportPoller {
`/_synapse/admin/v1/rooms/${report.room_id}/context/${report.event_id}?limit=1`
)).event;
} catch (ex) {
this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to get context: ${ex}`);
this.mjolnir.managementRoom.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to get context: ${ex}`);
continue;
}
@ -114,7 +114,7 @@ export class ReportPoller {
try {
await this.mjolnir.client.setAccountData(REPORT_POLL_EVENT_TYPE, { from: response.next_token });
} catch (ex) {
await this.mjolnir.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to update progress: ${ex}`);
await this.mjolnir.managementRoom.logMessage(LogLevel.ERROR, "getAbuseReports", `failed to update progress: ${ex}`);
}
}
}
@ -125,7 +125,7 @@ export class ReportPoller {
try {
await this.getAbuseReports()
} catch (ex) {
await this.mjolnir.logMessage(LogLevel.ERROR, "tryGetAbuseReports", `failed to get abuse reports: ${ex}`);
await this.mjolnir.managementRoom.logMessage(LogLevel.ERROR, "tryGetAbuseReports", `failed to get abuse reports: ${ex}`);
}
this.schedulePoll();

View File

@ -15,19 +15,13 @@ limitations under the License.
*/
import {
extractRequestError,
LogLevel,
LogService,
MatrixClient,
MatrixGlob,
MessageType,
Permalinks,
TextualMessageEventContent,
UserID,
getRequestFn,
setRequestFn,
} from "matrix-bot-sdk";
import { Mjolnir } from "./Mjolnir";
import { ClientRequest, IncomingMessage } from "http";
import { default as parseDuration } from "parse-duration";
import ManagementRoomOutput from "./ManagementRoom";

View File

@ -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);
}

View File

@ -242,12 +242,12 @@ describe('Test: ACL updates will batch when rules are added in succession.', fun
const room = await moderator.createRoom({ invite: [mjolnirId] });
await mjolnir.client.joinRoom(room);
await moderator.setUserPowerLevel(mjolnirId, room, 100);
await mjolnir.addProtectedRoom(room);
await mjolnir.protectedRoomsTracker.addProtectedRoom(room);
protectedRooms.push(room);
}
// If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point.
await mjolnir.syncLists();
await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
await Promise.all(protectedRooms.map(async room => {
// We're going to need timeline pagination I'm afraid.
const roomAcl = await mjolnir.client.getRoomStateEvent(room, "m.room.server_acl", "");
@ -269,7 +269,7 @@ describe('Test: ACL updates will batch when rules are added in succession.', fun
}
// We do this because it should force us to wait until all the ACL events have been applied.
// Even if that does mean the last few events will not go through batching...
await mjolnir.syncLists();
await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
// At this point we check that the state within Mjolnir is internally consistent, this is just because debugging the following
// is a pita.
@ -300,7 +300,7 @@ describe('Test: unbaning entities via the PolicyList.', function() {
it('Will remove rules that have legacy types', async function() {
const mjolnir: Mjolnir = this.mjolnir!
const serverName: string = new UserID(await mjolnir.client.getUserId()).domain
const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
const moderator: MatrixClient = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } });
this.moderator = moderator;
await moderator.joinRoom(mjolnir.managementRoomId);
const mjolnirId = await mjolnir.client.getUserId();
@ -309,10 +309,10 @@ describe('Test: unbaning entities via the PolicyList.', function() {
const protectedRoom = await moderator.createRoom({ invite: [mjolnirId] });
await mjolnir.client.joinRoom(protectedRoom);
await moderator.setUserPowerLevel(mjolnirId, protectedRoom, 100);
await mjolnir.addProtectedRoom(protectedRoom);
await mjolnir.protectedRoomsTracker.addProtectedRoom(protectedRoom);
// If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point.
await mjolnir.syncLists();
await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
// If this is not present, then it means the room isn't being protected, which is really bad.
const roomAcl = await mjolnir.client.getRoomStateEvent(protectedRoom, "m.room.server_acl", "");
assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.');
@ -358,7 +358,7 @@ describe('Test: unbaning entities via the PolicyList.', function() {
}
// Wait for mjolnir to sync protected rooms to update ACL.
await mjolnir.syncLists();
await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
// Confirm that the server is unbanned.
await banList.updateList();
assert.equal(banList.allRules.length, 0);
@ -382,12 +382,12 @@ describe('Test: should apply bans to the most recently active rooms first', func
const room = await moderator.createRoom({ invite: [mjolnirId] });
await mjolnir.client.joinRoom(room);
await moderator.setUserPowerLevel(mjolnirId, room, 100);
await mjolnir.addProtectedRoom(room);
await mjolnir.protectedRoomsTracker.addProtectedRoom(room);
protectedRooms.push(room);
}
// If a previous test hasn't cleaned up properly, these rooms will be populated by bogus ACLs at this point.
await mjolnir.syncLists();
await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
await Promise.all(protectedRooms.map(async room => {
const roomAcl = await mjolnir.client.getRoomStateEvent(room, "m.room.server_acl", "").catch(e => e.statusCode === 404 ? { deny: [] } : Promise.reject(e));
assert.equal(roomAcl?.deny?.length ?? 0, 0, 'There should be no entries in the deny ACL.');
@ -398,7 +398,7 @@ describe('Test: should apply bans to the most recently active rooms first', func
await mjolnir.client.joinRoom(banListId);
await mjolnir.watchList(Permalinks.forRoom(banListId));
await mjolnir.syncLists();
await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
// shuffle protected rooms https://stackoverflow.com/a/12646864, we do this so we can create activity "randomly" in them.
for (let i = protectedRooms.length - 1; i > 0; i--) {
@ -413,7 +413,7 @@ describe('Test: should apply bans to the most recently active rooms first', func
// check the rooms are in the expected order
for (let i = 0; i < protectedRooms.length; i++) {
assert.equal(mjolnir.protectedRoomsByActivity()[i], protectedRooms[i]);
assert.equal(mjolnir.protectedRoomsTracker.protectedRoomsByActivity()[i], protectedRooms[i]);
}
const badServer = `evil.com`;
@ -422,10 +422,10 @@ describe('Test: should apply bans to the most recently active rooms first', func
await createPolicyRule(moderator, banListId, RULE_SERVER, badServer, `Rule ${badServer}`);
// Wait until all the ACL events have been applied.
await mjolnir.syncLists();
await mjolnir.protectedRoomsTracker.syncLists(mjolnir.config.verboseLogging);
for (let i = 0; i < protectedRooms.length; i++) {
assert.equal(mjolnir.protectedRoomsByActivity()[i], protectedRooms.at(-i - 1));
assert.equal(mjolnir.protectedRoomsTracker.protectedRoomsByActivity()[i], protectedRooms.at(-i - 1));
}
// Check that the most recently active rooms got the ACL update first.