mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-07-01 00:51:36 +00:00
1205 lines
53 KiB
TypeScript
1205 lines
53 KiB
TypeScript
/*
|
|
Copyright 2019-2021 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 {
|
|
CreateEvent,
|
|
extractRequestError,
|
|
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 { 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 { 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,
|
|
};
|
|
|
|
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 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
|
|
*/
|
|
export const REPORT_POLL_EVENT_TYPE = "org.matrix.mjolnir.report_poll";
|
|
|
|
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
|
|
*/
|
|
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`.
|
|
*/
|
|
private protectedJoinedRoomIds: string[] = [];
|
|
/**
|
|
* 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[] = [];
|
|
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.
|
|
*/
|
|
public aclChain: Promise<void> = Promise.resolve();
|
|
/*
|
|
* Config-enabled polling of reports in Synapse, so Mjolnir can react to reports
|
|
*/
|
|
private reportPoller?: ReportPoller;
|
|
/**
|
|
* Adds a listener to the client that will automatically accept invitations.
|
|
* @param {MatrixClient} client
|
|
* @param options By default accepts invites from anyone.
|
|
* @param {string} options.managementRoom The room to report ignored invitations to if `recordIgnoredInvites` is true.
|
|
* @param {boolean} options.recordIgnoredInvites Whether to report invites that will be ignored to the `managementRoom`.
|
|
* @param {boolean} options.autojoinOnlyIfManager Whether to only accept an invitation by a user present in the `managementRoom`.
|
|
* @param {string} options.acceptInvitesFromGroup A group of users to accept invites from, ignores invites form users not in this group.
|
|
*/
|
|
private static addJoinOnInviteListener(mjolnir: Mjolnir, client: MatrixClient, options: { [key: string]: any }) {
|
|
client.on("room.invite", async (roomId: string, inviteEvent: any) => {
|
|
const membershipEvent = new MembershipEvent(inviteEvent);
|
|
|
|
const reportInvite = async () => {
|
|
if (!options.recordIgnoredInvites) return; // Nothing to do
|
|
|
|
await client.sendMessage(mjolnir.managementRoomId, {
|
|
msgtype: "m.text",
|
|
body: `${membershipEvent.sender} has invited me to ${roomId} but the config prevents me from accepting the invitation. `
|
|
+ `If you would like this room protected, use "!mjolnir rooms add ${roomId}" so I can accept the invite.`,
|
|
format: "org.matrix.custom.html",
|
|
formatted_body: `${htmlEscape(membershipEvent.sender)} has invited me to ${htmlEscape(roomId)} but the config prevents me from `
|
|
+ `accepting the invitation. If you would like this room protected, use <code>!mjolnir rooms add ${htmlEscape(roomId)}</code> `
|
|
+ `so I can accept the invite.`,
|
|
});
|
|
};
|
|
|
|
if (options.autojoinOnlyIfManager) {
|
|
const managers = await client.getJoinedRoomMembers(mjolnir.managementRoomId);
|
|
if (!managers.includes(membershipEvent.sender)) return reportInvite(); // ignore invite
|
|
} else {
|
|
const groupMembers = await client.unstableApis.getGroupUsers(options.acceptInvitesFromGroup);
|
|
const userIds = groupMembers.map(m => m.user_id);
|
|
if (!userIds.includes(membershipEvent.sender)) return reportInvite(); // ignore invite
|
|
}
|
|
|
|
return client.joinRoom(roomId);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a new Mjolnir instance from a client and the options in the configuration file, ready to be started.
|
|
* @param {MatrixClient} client The client for Mjolnir to use.
|
|
* @returns A new Mjolnir instance that can be started without further setup.
|
|
*/
|
|
static async setupMjolnirFromConfig(client: MatrixClient, config: IConfig): Promise<Mjolnir> {
|
|
const policyLists: PolicyList[] = [];
|
|
const protectedRooms: { [roomId: string]: string } = {};
|
|
const joinedRooms = await client.getJoinedRooms();
|
|
// Ensure we're also joined to the rooms we're protecting
|
|
LogService.info("index", "Resolving protected rooms...");
|
|
for (const roomRef of config.protectedRooms) {
|
|
const permalink = Permalinks.parseUrl(roomRef);
|
|
if (!permalink.roomIdOrAlias) continue;
|
|
|
|
let roomId = await client.resolveRoom(permalink.roomIdOrAlias);
|
|
if (!joinedRooms.includes(roomId)) {
|
|
roomId = await client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers);
|
|
}
|
|
|
|
protectedRooms[roomId] = roomRef;
|
|
}
|
|
|
|
// Ensure we're also in the management room
|
|
LogService.info("index", "Resolving management room...");
|
|
const managementRoomId = await client.resolveRoom(config.managementRoom);
|
|
if (!joinedRooms.includes(managementRoomId)) {
|
|
await client.joinRoom(config.managementRoom);
|
|
}
|
|
|
|
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.");
|
|
Mjolnir.addJoinOnInviteListener(mjolnir, client, config);
|
|
return mjolnir;
|
|
}
|
|
|
|
constructor(
|
|
public readonly client: MatrixClient,
|
|
public readonly managementRoomId: string,
|
|
public readonly config: IConfig,
|
|
/*
|
|
* 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 },
|
|
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));
|
|
|
|
client.on("room.message", async (roomId, event) => {
|
|
if (roomId !== this.managementRoomId) return;
|
|
if (!event['content']) return;
|
|
|
|
const content = event['content'];
|
|
if (content['msgtype'] === "m.text" && content['body']) {
|
|
const prefixes = [
|
|
COMMAND_PREFIX,
|
|
this.localpart + ":",
|
|
this.displayName + ":",
|
|
await client.getUserId() + ":",
|
|
this.localpart + " ",
|
|
this.displayName + " ",
|
|
await client.getUserId() + " ",
|
|
...config.commands.additionalPrefixes.map(p => `!${p}`),
|
|
...config.commands.additionalPrefixes.map(p => `${p}:`),
|
|
...config.commands.additionalPrefixes.map(p => `${p} `),
|
|
...config.commands.additionalPrefixes,
|
|
];
|
|
if (config.commands.allowNoPrefix) prefixes.push("!");
|
|
|
|
const prefixUsed = prefixes.find(p => content['body'].toLowerCase().startsWith(p.toLowerCase()));
|
|
if (!prefixUsed) return;
|
|
|
|
// rewrite the event body to make the prefix uniform (in case the bot has spaces in its display name)
|
|
let restOfBody = content['body'].substring(prefixUsed.length);
|
|
if (!restOfBody.startsWith(" ")) restOfBody = ` ${restOfBody}`;
|
|
event['content']['body'] = COMMAND_PREFIX + restOfBody;
|
|
LogService.info("Mjolnir", `Command being run by ${event['sender']}: ${event['content']['body']}`);
|
|
|
|
await client.sendReadReceipt(roomId, event['event_id']);
|
|
return handleCommand(roomId, event, this);
|
|
}
|
|
});
|
|
|
|
client.on("room.join", (roomId: string, event: any) => {
|
|
LogService.info("Mjolnir", `Joined ${roomId}`);
|
|
return this.resyncJoinedRooms();
|
|
});
|
|
client.on("room.leave", (roomId: string, event: any) => {
|
|
LogService.info("Mjolnir", `Left ${roomId}`);
|
|
return this.resyncJoinedRooms();
|
|
});
|
|
|
|
client.getUserId().then(userId => {
|
|
this.localpart = userId.split(':')[0].substring(1);
|
|
return client.getUserProfile(userId);
|
|
}).then(profile => {
|
|
if (profile['displayname']) {
|
|
this.displayName = profile['displayname'];
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
if (config.pollReports) {
|
|
this.reportPoller = new ReportPoller(this, reportManager);
|
|
}
|
|
// Setup join/leave listener
|
|
this.roomJoins = new RoomMemberManager(this.client);
|
|
this.taskQueue = new ThrottlingQueue(this, config.backgroundDelayMS);
|
|
}
|
|
|
|
public get lists(): PolicyList[] {
|
|
return this.policyLists;
|
|
}
|
|
|
|
public get state(): string {
|
|
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.
|
|
* It cannot used to redact any previous messages the user has sent, in that cas you should use the `EventRedactionQueue`.
|
|
*/
|
|
public get unlistedUserRedactionHandler(): UnlistedUserRedactionQueue {
|
|
return this.unlistedUserRedactionQueue;
|
|
}
|
|
|
|
public get automaticRedactGlobs(): MatrixGlob[] {
|
|
return this.automaticRedactionReasons;
|
|
}
|
|
|
|
/**
|
|
* Start Mjölnir.
|
|
*/
|
|
public async start() {
|
|
try {
|
|
// Start the bot.
|
|
await this.client.start();
|
|
|
|
// Start the web server.
|
|
console.log("Starting web server");
|
|
await this.webapis.start();
|
|
|
|
if (this.reportPoller) {
|
|
let reportPollSetting: { from: number } = { from: 0 };
|
|
try {
|
|
reportPollSetting = await this.client.getAccountData(REPORT_POLL_EVENT_TYPE);
|
|
} catch (err) {
|
|
if (err.body?.errcode !== "M_NOT_FOUND") {
|
|
throw err;
|
|
} else {
|
|
this.logMessage(LogLevel.INFO, "Mjolnir@startup", "report poll setting does not exist yet");
|
|
}
|
|
}
|
|
this.reportPoller.start(reportPollSetting.from);
|
|
}
|
|
|
|
// Load the state.
|
|
this.currentState = STATE_CHECKING_PERMISSIONS;
|
|
|
|
await this.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.buildWatchedPolicyLists();
|
|
this.applyUnprotectedRooms();
|
|
|
|
if (this.config.verifyPermissionsOnStartup) {
|
|
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions...");
|
|
await this.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();
|
|
}
|
|
|
|
this.currentState = STATE_RUNNING;
|
|
await this.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");
|
|
throw err;
|
|
} catch (e) {
|
|
LogService.error("Mjolnir", `Failed to report startup error to the management room: ${e}`);
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop Mjolnir from syncing and processing commands.
|
|
*/
|
|
public stop() {
|
|
LogService.info("Mjolnir", "Stopping Mjolnir...");
|
|
this.client.stop();
|
|
this.webapis.stop();
|
|
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);
|
|
|
|
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 async removeProtectedRoom(roomId: string) {
|
|
delete this.protectedRooms[roomId];
|
|
this.roomJoins.removeRoom(roomId);
|
|
this.protectedRoomActivityTracker.removeProtectedRoom(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);
|
|
}
|
|
|
|
private async resyncJoinedRooms(withSync = true) {
|
|
if (!this.config.protectAllJoinedRooms) return;
|
|
|
|
const joinedRoomIds = (await this.client.getJoinedRooms()).filter(r => r !== this.managementRoomId);
|
|
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);
|
|
if (!joinedRoomIdsSet.has(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);
|
|
if (!oldRoomIdsSet.has(roomId)) {
|
|
this.roomJoins.addRoom(roomId);
|
|
}
|
|
}
|
|
|
|
this.applyUnprotectedRooms();
|
|
|
|
if (withSync) {
|
|
await this.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.
|
|
* @param roomId The room id for the `PolicyList`.
|
|
* @param roomRef A reference (matrix.to URL) for the `PolicyList`.
|
|
*/
|
|
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));
|
|
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);
|
|
if (!permalink.roomIdOrAlias) return null;
|
|
|
|
const roomId = await this.client.resolveRoom(permalink.roomIdOrAlias);
|
|
if (!joinedRooms.includes(roomId)) {
|
|
await this.client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers);
|
|
}
|
|
|
|
if (this.policyLists.find(b => b.roomId === roomId)) return null;
|
|
|
|
const list = await this.addPolicyList(roomId, roomRef);
|
|
|
|
await this.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, {
|
|
references: this.policyLists.map(b => b.roomRef),
|
|
});
|
|
|
|
await this.warnAboutUnprotectedPolicyListRoom(roomId);
|
|
|
|
return list;
|
|
}
|
|
|
|
public async unwatchList(roomRef: string): Promise<PolicyList | null> {
|
|
const permalink = Permalinks.parseUrl(roomRef);
|
|
if (!permalink.roomIdOrAlias) return null;
|
|
|
|
const roomId = await this.client.resolveRoom(permalink.roomIdOrAlias);
|
|
const list = this.policyLists.find(b => b.roomId === roomId) || null;
|
|
if (list) {
|
|
this.policyLists.splice(this.policyLists.indexOf(list), 1);
|
|
this.ruleServer?.unwatch(list);
|
|
}
|
|
|
|
await this.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, {
|
|
references: this.policyLists.map(b => b.roomRef),
|
|
});
|
|
return list;
|
|
}
|
|
|
|
public async warnAboutUnprotectedPolicyListRoom(roomId: string) {
|
|
if (!this.config.protectAllJoinedRooms) return; // doesn't matter
|
|
if (this.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();
|
|
|
|
try {
|
|
const accountData: { warned: boolean } | null = await this.client.getAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId);
|
|
if (accountData && accountData.warned) return; // already warned
|
|
} catch (e) {
|
|
// 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.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();
|
|
|
|
let watchedListsEvent: { references?: string[] } | null = null;
|
|
try {
|
|
watchedListsEvent = await this.client.getAccountData(WATCHED_LISTS_EVENT_TYPE);
|
|
} catch (e) {
|
|
// ignore - not important
|
|
}
|
|
|
|
for (const roomRef of (watchedListsEvent?.references || [])) {
|
|
const permalink = Permalinks.parseUrl(roomRef);
|
|
if (!permalink.roomIdOrAlias) continue;
|
|
|
|
const roomId = await this.client.resolveRoom(permalink.roomIdOrAlias);
|
|
if (!joinedRooms.includes(roomId)) {
|
|
await this.client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers);
|
|
}
|
|
|
|
await this.warnAboutUnprotectedPolicyListRoom(roomId);
|
|
await this.addPolicyList(roomId, roomRef);
|
|
}
|
|
}
|
|
|
|
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 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) {
|
|
if (event['type'] === 'm.room.message' && event['content'] && event['content']['body']) {
|
|
if (event['content']['body'] === "** Unable to decrypt: The sender's device has not sent us the keys for this message. **") {
|
|
// UISI
|
|
await this.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '⚠');
|
|
await this.client.unstableApis.addReactionToEvent(roomId, event['event_id'], 'UISI');
|
|
await this.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '🚨');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for updated ban lists before checking protected rooms - the ban lists might be protected
|
|
// themselves.
|
|
const policyList = this.policyLists.find(list => list.roomId === roomId);
|
|
if (policyList !== undefined) {
|
|
if (ALL_BAN_LIST_RULE_TYPES.includes(event['type']) || event['type'] === 'm.room.redaction') {
|
|
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);
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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`;
|
|
const response = await this.client.doRequest("GET", endpoint);
|
|
return response['admin'];
|
|
} catch (e) {
|
|
LogService.error("Mjolnir", "Error determining if Mjolnir is a server admin:");
|
|
LogService.error("Mjolnir", extractRequestError(e));
|
|
return false; // assume not
|
|
}
|
|
}
|
|
|
|
public async deactivateSynapseUser(userId: string): Promise<any> {
|
|
const endpoint = `/_synapse/admin/v1/deactivate/${userId}`;
|
|
return await this.client.doRequest("POST", endpoint);
|
|
}
|
|
|
|
public async shutdownSynapseRoom(roomId: string, message?: string): Promise<any> {
|
|
const endpoint = `/_synapse/admin/v1/rooms/${roomId}`;
|
|
return await this.client.doRequest("DELETE", endpoint, null, {
|
|
new_room_user_id: await this.client.getUserId(),
|
|
block: true,
|
|
message: message /* If `undefined`, we'll use Synapse's default message. */
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Make a user administrator via the Synapse Admin API
|
|
* @param roomId the room where the user (or the bot) shall be made administrator.
|
|
* @param userId optionally specify the user mxID to be made administrator, if not specified the bot mxID will be used.
|
|
* @returns The list of errors encountered, for reporting to the management room.
|
|
*/
|
|
public async makeUserRoomAdmin(roomId: string, userId?: string): Promise<any> {
|
|
try {
|
|
const endpoint = `/_synapse/admin/v1/rooms/${roomId}/make_room_admin`;
|
|
return await this.client.doRequest("POST", endpoint, null, {
|
|
user_id: userId || await this.client.getUserId(), /* if not specified make the bot administrator */
|
|
});
|
|
} catch (e) {
|
|
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);
|
|
}
|
|
}
|
|
}
|