Refactor how we listen for matrix events. (#446)

* Refactor Matrix event listener in Mjolnir and ManagedMjolnir.

closes https://github.com/matrix-org/mjolnir/issues/411.

Issue #411 says that we have to be careful about room.join,
but this was before we figured how to make matrix-appservice-bridge
echo events sent by its own intents.

* Remove MatrixClientListener since it isn't actually needed.

* Protect which config values can be used for ManagedMjolnirs.

* Introduce MatrixSendClient

so listeners aren't accidentally added to a MatrixClient instead
of MatrixEmitter.

* doc

* Move provisioned mjolnir config to src/config.

This just aids maintance so whenever someone goes to change the config
of the bot they will see this and update it.

* doc for matrix intent listener.
This commit is contained in:
Gnuxie 2022-12-06 17:17:40 +00:00 committed by GitHub
parent 262e80acc2
commit 704bb660c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 242 additions and 91 deletions

View File

@ -15,8 +15,9 @@ limitations under the License.
*/ */
import * as Sentry from "@sentry/node"; import * as Sentry from "@sentry/node";
import { extractRequestError, LogLevel, LogService, MatrixClient, MessageType, Permalinks, TextualMessageEventContent, UserID } from "matrix-bot-sdk"; import { extractRequestError, LogLevel, LogService, MessageType, Permalinks, TextualMessageEventContent, UserID } from "matrix-bot-sdk";
import { IConfig } from "./config"; import { IConfig } from "./config";
import { MatrixSendClient } from "./MatrixEmitter";
import { htmlEscape } from "./utils"; import { htmlEscape } from "./utils";
const levelToFn = { const levelToFn = {
@ -33,7 +34,7 @@ export default class ManagementRoomOutput {
constructor( constructor(
private readonly managementRoomId: string, private readonly managementRoomId: string,
private readonly client: MatrixClient, private readonly client: MatrixSendClient,
private readonly config: IConfig, private readonly config: IConfig,
) { ) {

55
src/MatrixEmitter.ts Normal file
View File

@ -0,0 +1,55 @@
/*
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import EventEmitter from "events";
import { MatrixClient } from "matrix-bot-sdk";
/**
* This is an interface created in order to keep the event listener
* Mjolnir uses for new events generic.
* Used to provide a unified API for messages received from matrix-bot-sdk (using GET /sync)
* when we're in single bot mode and messages received from matrix-appservice-bridge (using pushed /transaction)
* when we're in appservice mode.
*/
export declare interface MatrixEmitter extends EventEmitter {
on(event: 'room.event', listener: (roomId: string, mxEvent: any) => void ): this
emit(event: 'room.event', roomId: string, mxEvent: any): boolean
on(event: 'room.message', listener: (roomId: string, mxEvent: any) => void ): this
emit(event: 'room.message', roomId: string, mxEvent: any): boolean
on(event: 'room.invite', listener: (roomId: string, mxEvent: any) => void ): this
emit(event: 'room.invite', roomId: string, mxEvent: any): boolean
on(event: 'room.join', listener: (roomId: string, mxEvent: any) => void ): this
emit(event: 'room.join', roomId: string, mxEvent: any): boolean
on(event: 'room.leave', listener: (roomId: string, mxEvent: any) => void ): this
emit(event: 'room.leave', roomId: string, mxEvent: any): boolean
on(event: 'room.archived', listener: (roomId: string, mxEvent: any) => void ): this
emit(event: 'room.archived', roomId: string, mxEvent: any): boolean
start(): Promise<void>;
stop(): void;
}
/**
* A `MatrixClient` without the properties of `MatrixEmitter`.
* This is in order to enforce listeners are added to `MatrixEmitter`s
* rather than on the matrix-bot-sdk version of the matrix client.
*/
export type MatrixSendClient = Omit<MatrixClient, keyof MatrixEmitter>;

View File

@ -18,7 +18,6 @@ import {
extractRequestError, extractRequestError,
LogLevel, LogLevel,
LogService, LogService,
MatrixClient,
MembershipEvent, MembershipEvent,
Permalinks, Permalinks,
} from "matrix-bot-sdk"; } from "matrix-bot-sdk";
@ -39,6 +38,7 @@ import ManagementRoomOutput from "./ManagementRoomOutput";
import { ProtectionManager } from "./protections/ProtectionManager"; import { ProtectionManager } from "./protections/ProtectionManager";
import { RoomMemberManager } from "./RoomMembers"; import { RoomMemberManager } from "./RoomMembers";
import ProtectedRoomsConfig from "./ProtectedRoomsConfig"; import ProtectedRoomsConfig from "./ProtectedRoomsConfig";
import { MatrixEmitter, MatrixSendClient } from "./MatrixEmitter";
export const STATE_NOT_STARTED = "not_started"; export const STATE_NOT_STARTED = "not_started";
export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
@ -88,15 +88,15 @@ export class Mjolnir {
/** /**
* Adds a listener to the client that will automatically accept invitations. * Adds a listener to the client that will automatically accept invitations.
* @param {MatrixClient} client * @param {MatrixSendClient} client
* @param options By default accepts invites from anyone. * @param options By default accepts invites from anyone.
* @param {string} options.managementRoom The room to report ignored invitations to if `recordIgnoredInvites` is true. * @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.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 {boolean} options.autojoinOnlyIfManager Whether to only accept an invitation by a user present in the `managementRoom`.
* @param {string} options.acceptInvitesFromSpace A space of users to accept invites from, ignores invites form users not in this space. * @param {string} options.acceptInvitesFromSpace A space of users to accept invites from, ignores invites form users not in this space.
*/ */
private static addJoinOnInviteListener(mjolnir: Mjolnir, client: MatrixClient, options: { [key: string]: any }) { private static addJoinOnInviteListener(mjolnir: Mjolnir, client: MatrixSendClient, options: { [key: string]: any }) {
client.on("room.invite", async (roomId: string, inviteEvent: any) => { mjolnir.matrixEmitter.on("room.invite", async (roomId: string, inviteEvent: any) => {
const membershipEvent = new MembershipEvent(inviteEvent); const membershipEvent = new MembershipEvent(inviteEvent);
const reportInvite = async () => { const reportInvite = async () => {
@ -130,17 +130,16 @@ export class Mjolnir {
}); });
if (!spaceUserIds.includes(membershipEvent.sender)) return reportInvite(); // ignore invite if (!spaceUserIds.includes(membershipEvent.sender)) return reportInvite(); // ignore invite
} }
return client.joinRoom(roomId); return client.joinRoom(roomId);
}); });
} }
/** /**
* Create a new Mjolnir instance from a client and the options in the configuration file, ready to be started. * 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. * @param {MatrixSendClient} client The client for Mjolnir to use.
* @returns A new Mjolnir instance that can be started without further setup. * @returns A new Mjolnir instance that can be started without further setup.
*/ */
static async setupMjolnirFromConfig(client: MatrixClient, config: IConfig): Promise<Mjolnir> { static async setupMjolnirFromConfig(client: MatrixSendClient, matrixEmitter: MatrixEmitter, config: IConfig): Promise<Mjolnir> {
const policyLists: PolicyList[] = []; const policyLists: PolicyList[] = [];
const joinedRooms = await client.getJoinedRooms(); const joinedRooms = await client.getJoinedRooms();
@ -152,15 +151,16 @@ export class Mjolnir {
} }
const ruleServer = config.web.ruleServer ? new RuleServer() : null; const ruleServer = config.web.ruleServer ? new RuleServer() : null;
const mjolnir = new Mjolnir(client, await client.getUserId(), managementRoomId, config, policyLists, ruleServer); const mjolnir = new Mjolnir(client, await client.getUserId(), matrixEmitter, managementRoomId, config, policyLists, ruleServer);
await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status."); await mjolnir.managementRoomOutput.logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status.");
Mjolnir.addJoinOnInviteListener(mjolnir, client, config); Mjolnir.addJoinOnInviteListener(mjolnir, client, config);
return mjolnir; return mjolnir;
} }
constructor( constructor(
public readonly client: MatrixClient, public readonly client: MatrixSendClient,
private readonly clientUserId: string, private readonly clientUserId: string,
public readonly matrixEmitter: MatrixEmitter,
public readonly managementRoomId: string, public readonly managementRoomId: string,
public readonly config: IConfig, public readonly config: IConfig,
private policyLists: PolicyList[], private policyLists: PolicyList[],
@ -171,9 +171,9 @@ export class Mjolnir {
// Setup bot. // Setup bot.
client.on("room.event", this.handleEvent.bind(this)); matrixEmitter.on("room.event", this.handleEvent.bind(this));
client.on("room.message", async (roomId, event) => { matrixEmitter.on("room.message", async (roomId, event) => {
if (roomId !== this.managementRoomId) return; if (roomId !== this.managementRoomId) return;
if (!event['content']) return; if (!event['content']) return;
@ -208,11 +208,11 @@ export class Mjolnir {
} }
}); });
client.on("room.join", (roomId: string, event: any) => { matrixEmitter.on("room.join", (roomId: string, event: any) => {
LogService.info("Mjolnir", `Joined ${roomId}`); LogService.info("Mjolnir", `Joined ${roomId}`);
return this.resyncJoinedRooms(); return this.resyncJoinedRooms();
}); });
client.on("room.leave", (roomId: string, event: any) => { matrixEmitter.on("room.leave", (roomId: string, event: any) => {
LogService.info("Mjolnir", `Left ${roomId}`); LogService.info("Mjolnir", `Left ${roomId}`);
return this.resyncJoinedRooms(); return this.resyncJoinedRooms();
}); });
@ -234,7 +234,7 @@ export class Mjolnir {
this.reportPoller = new ReportPoller(this, this.reportManager); this.reportPoller = new ReportPoller(this, this.reportManager);
} }
// Setup join/leave listener // Setup join/leave listener
this.roomJoins = new RoomMemberManager(this.client); this.roomJoins = new RoomMemberManager(this.matrixEmitter);
this.taskQueue = new ThrottlingQueue(this, config.backgroundDelayMS); this.taskQueue = new ThrottlingQueue(this, config.backgroundDelayMS);
this.protectionManager = new ProtectionManager(this); this.protectionManager = new ProtectionManager(this);
@ -303,7 +303,7 @@ export class Mjolnir {
} }
// Start the bot. // Start the bot.
await this.client.start(); await this.matrixEmitter.start();
this.currentState = STATE_SYNCING; this.currentState = STATE_SYNCING;
if (this.config.syncOnStartup) { if (this.config.syncOnStartup) {
@ -331,7 +331,7 @@ export class Mjolnir {
*/ */
public stop() { public stop() {
LogService.info("Mjolnir", "Stopping Mjolnir..."); LogService.info("Mjolnir", "Stopping Mjolnir...");
this.client.stop(); this.matrixEmitter.stop();
this.webapis.stop(); this.webapis.stop();
this.reportPoller?.stop(); this.reportPoller?.stop();
} }

View File

@ -15,8 +15,9 @@ limitations under the License.
*/ */
import AwaitLock from 'await-lock'; import AwaitLock from 'await-lock';
import { extractRequestError, LogService, MatrixClient, Permalinks } from "matrix-bot-sdk"; import { extractRequestError, LogService, Permalinks } from "matrix-bot-sdk";
import { IConfig } from "./config"; import { IConfig } from "./config";
import { MatrixSendClient } from './MatrixEmitter';
const PROTECTED_ROOMS_EVENT_TYPE = "org.matrix.mjolnir.protected_rooms"; const PROTECTED_ROOMS_EVENT_TYPE = "org.matrix.mjolnir.protected_rooms";
/** /**
@ -32,7 +33,7 @@ export default class ProtectedRoomsConfig {
/** This is to prevent clobbering the account data for the protected rooms if several rooms are explicitly protected concurrently. */ /** This is to prevent clobbering the account data for the protected rooms if several rooms are explicitly protected concurrently. */
private accountDataLock = new AwaitLock(); private accountDataLock = new AwaitLock();
constructor(private readonly client: MatrixClient) { constructor(private readonly client: MatrixSendClient) {
} }

View File

@ -14,10 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { LogLevel, LogService, MatrixClient, MatrixGlob, Permalinks, UserID } from "matrix-bot-sdk"; import { LogLevel, LogService, MatrixGlob, Permalinks, UserID } from "matrix-bot-sdk";
import { IConfig } from "./config"; import { IConfig } from "./config";
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache"; import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
import ManagementRoomOutput from "./ManagementRoomOutput"; import ManagementRoomOutput from "./ManagementRoomOutput";
import { MatrixSendClient } from "./MatrixEmitter";
import AccessControlUnit, { Access } from "./models/AccessControlUnit"; import AccessControlUnit, { Access } from "./models/AccessControlUnit";
import { RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/ListRule"; import { RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/ListRule";
import PolicyList, { ListRuleChange } from "./models/PolicyList"; import PolicyList, { ListRuleChange } from "./models/PolicyList";
@ -88,7 +89,7 @@ export class ProtectedRoomsSet {
private readonly accessControlUnit = new AccessControlUnit([]); private readonly accessControlUnit = new AccessControlUnit([]);
constructor( constructor(
private readonly client: MatrixClient, private readonly client: MatrixSendClient,
private readonly clientUserId: string, private readonly clientUserId: string,
private readonly managementRoomId: string, private readonly managementRoomId: string,
private readonly managementRoomOutput: ManagementRoomOutput, private readonly managementRoomOutput: ManagementRoomOutput,

View File

@ -1,4 +1,4 @@
import { MatrixClient } from "matrix-bot-sdk"; import { MatrixEmitter } from "./MatrixEmitter";
enum Action { enum Action {
Join, Join,
@ -154,7 +154,7 @@ class RoomMembers {
export class RoomMemberManager { export class RoomMemberManager {
private perRoom: Map<string /* room id */, RoomMembers> = new Map(); private perRoom: Map<string /* room id */, RoomMembers> = new Map();
private readonly cbHandleEvent; private readonly cbHandleEvent;
constructor(private client: MatrixClient) { constructor(private client: MatrixEmitter) {
// Listen for join events. // Listen for join events.
this.cbHandleEvent = this.handleEvent.bind(this); this.cbHandleEvent = this.handleEvent.bind(this);
client.on("room.event", this.cbHandleEvent); client.on("room.event", this.cbHandleEvent);

View File

@ -1,12 +1,16 @@
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import { Request, WeakEvent, BridgeContext, Bridge, Intent } from "matrix-appservice-bridge"; import { Request, WeakEvent, BridgeContext, Bridge, Intent, Logger } from "matrix-appservice-bridge";
import { IConfig, read as configRead } from "../config"; import { getProvisionedMjolnirConfig } from "../config";
import PolicyList from "../models/PolicyList"; import PolicyList from "../models/PolicyList";
import { Permalinks, MatrixClient } from "matrix-bot-sdk"; import { Permalinks, MatrixClient } from "matrix-bot-sdk";
import { DataStore } from "./datastore"; import { DataStore } from "./datastore";
import { AccessControl } from "./AccessControl"; import { AccessControl } from "./AccessControl";
import { Access } from "../models/AccessControlUnit"; import { Access } from "../models/AccessControlUnit";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import EventEmitter from "events";
import { MatrixEmitter } from "../MatrixEmitter";
const log = new Logger('MjolnirManager');
/** /**
* The MjolnirManager is responsible for: * The MjolnirManager is responsible for:
@ -38,18 +42,6 @@ export class MjolnirManager {
return mjolnirManager; return mjolnirManager;
} }
/**
* Gets the default config to give the newly provisioned mjolnirs.
* @param managementRoomId A room that has been created to serve as the mjolnir's management room for the owner.
* @returns A config that can be directly used by the new mjolnir.
*/
private getDefaultMjolnirConfig(managementRoomId: string): IConfig {
let config = configRead();
config.managementRoom = managementRoomId;
config.protectedRooms = [];
return config;
}
/** /**
* Creates a new mjolnir for a user. * Creates a new mjolnir for a user.
* @param requestingUserId The user that is requesting this mjolnir and who will own it. * @param requestingUserId The user that is requesting this mjolnir and who will own it.
@ -58,10 +50,17 @@ export class MjolnirManager {
* @returns A new managed mjolnir. * @returns A new managed mjolnir.
*/ */
public async makeInstance(requestingUserId: string, managementRoomId: string, client: MatrixClient): Promise<ManagedMjolnir> { public async makeInstance(requestingUserId: string, managementRoomId: string, client: MatrixClient): Promise<ManagedMjolnir> {
const intentListener = new MatrixIntentListener(await client.getUserId());
const managedMjolnir = new ManagedMjolnir( const managedMjolnir = new ManagedMjolnir(
requestingUserId, requestingUserId,
await Mjolnir.setupMjolnirFromConfig(client, this.getDefaultMjolnirConfig(managementRoomId)) await Mjolnir.setupMjolnirFromConfig(
client,
intentListener,
getProvisionedMjolnirConfig(managementRoomId)
),
intentListener,
); );
await managedMjolnir.start();
this.mjolnirs.set(await client.getUserId(), managedMjolnir); this.mjolnirs.set(await client.getUserId(), managedMjolnir);
return managedMjolnir; return managedMjolnir;
} }
@ -170,7 +169,11 @@ export class MjolnirManager {
mjolnirRecord.owner, mjolnirRecord.owner,
mjolnirRecord.management_room, mjolnirRecord.management_room,
mjIntent.matrixClient, mjIntent.matrixClient,
); ).catch((e: any) => {
log.error(`Could not start mjolnir ${mjolnirRecord.local_part} for ${mjolnirRecord.owner}:`, e);
// Don't await, we don't want to clobber initialization if this fails.
mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your mjolnir could not be started. Please alert the administrator`);
});
} }
} }
} }
@ -180,25 +183,11 @@ export class ManagedMjolnir {
public constructor( public constructor(
public readonly ownerId: string, public readonly ownerId: string,
private readonly mjolnir: Mjolnir, private readonly mjolnir: Mjolnir,
private readonly matrixEmitter: MatrixIntentListener,
) { } ) { }
public async onEvent(request: Request<WeakEvent>) { public async onEvent(request: Request<WeakEvent>) {
// Emulate the client syncing. this.matrixEmitter.handleEvent(request.getData());
// https://github.com/matrix-org/mjolnir/issues/411
const mxEvent = request.getData();
if (mxEvent['type'] !== undefined) {
this.mjolnir.client.emit('room.event', mxEvent.room_id, mxEvent);
if (mxEvent.type === 'm.room.message') {
this.mjolnir.client.emit('room.message', mxEvent.room_id, mxEvent);
}
// TODO: We need to figure out how to inform the mjolnir of `room.join`.
// https://github.com/matrix-org/mjolnir/issues/411
}
if (mxEvent['type'] === 'm.room.member') {
if (mxEvent['content']['membership'] === 'invite' && mxEvent.state_key === await this.mjolnir.client.getUserId()) {
this.mjolnir.client.emit('room.invite', mxEvent.room_id, mxEvent);
}
}
} }
public async joinRoom(roomId: string) { public async joinRoom(roomId: string) {
@ -223,4 +212,63 @@ export class ManagedMjolnir {
public get managementRoomId(): string { public get managementRoomId(): string {
return this.mjolnir.managementRoomId; return this.mjolnir.managementRoomId;
} }
/**
* Intended to be called by the MjolnirManager to make sure the mjolnir is ready to listen to events.
* This managed mjolnir should not be informed of any events via `onEvent` until `start` is called.
*/
public async start(): Promise<void> {
await this.mjolnir.start();
}
}
/**
* This is used to listen for events intended for a single mjolnir that resides in the appservice.
* This exists entirely because the Mjolnir class was previously designed only to receive events
* from a syncing matrix-bot-sdk MatrixClient. Since appservices provide a transactional push
* api for all users on the appservice, almost the opposite of sync, we needed to create an
* interface for both. See `MatrixEmitter`.
*/
export class MatrixIntentListener extends EventEmitter implements MatrixEmitter {
constructor(private readonly mjolnirId: string) {
super()
}
public handleEvent(mxEvent: WeakEvent) {
// These are ordered to be the same as matrix-bot-sdk's MatrixClient
// They shouldn't need to be, but they are just in case it matters.
if (mxEvent['type'] === 'm.room.member' && mxEvent.state_key === this.mjolnirId) {
if (mxEvent['content']['membership'] === 'leave') {
this.emit('room.leave', mxEvent.room_id, mxEvent);
}
if (mxEvent['content']['membership'] === 'invite') {
this.emit('room.invite', mxEvent.room_id, mxEvent);
}
if (mxEvent['content']['membership'] === 'join') {
this.emit('room.join', mxEvent.room_id, mxEvent);
}
}
if (mxEvent.type === 'm.room.message') {
this.emit('room.message', mxEvent.room_id, mxEvent);
}
if (mxEvent.type === 'm.room.tombstone' && mxEvent.state_key === '') {
this.emit('room.archived', mxEvent.room_id, mxEvent);
}
this.emit('room.event', mxEvent.room_id, mxEvent);
}
/**
* To be called by `Mjolnir`.
*/
public async start() {
// Nothing to do.
}
/**
* To be called by `Mjolnir`.
*/
public stop() {
// Nothing to do.
}
} }

View File

@ -16,7 +16,7 @@ limitations under the License.
import * as fs from "fs"; import * as fs from "fs";
import { load } from "js-yaml"; import { load } from "js-yaml";
import { MatrixClient } from "matrix-bot-sdk"; import { MatrixClient, LogService } from "matrix-bot-sdk";
import Config from "config"; import Config from "config";
/** /**
@ -180,6 +180,10 @@ const defaultConfig: IConfig = {
}, },
}; };
export function getDefaultConfig(): IConfig {
return Config.util.cloneDeep(defaultConfig);
}
/** /**
* Grabs an explicit path provided for mjolnir's config from an arguments vector if provided, otherwise returns undefined. * Grabs an explicit path provided for mjolnir's config from an arguments vector if provided, otherwise returns undefined.
* @param argv An arguments vector sourced from `process.argv`. * @param argv An arguments vector sourced from `process.argv`.
@ -209,3 +213,40 @@ export function read(): IConfig {
return config; return config;
} }
} }
/**
* Provides a config for each newly provisioned mjolnir in appservice mode.
* @param managementRoomId A room that has been created to serve as the mjolnir's management room for the owner.
* @returns A config that can be directly used by the new mjolnir.
*/
export function getProvisionedMjolnirConfig(managementRoomId: string): IConfig {
// These are keys that are allowed to be configured for provisioned mjolnirs.
// We need a restricted set so that someone doesn't accidentally enable webservers etc
// on every created Mjolnir, which would result in very confusing error messages.
const allowedKeys = [
"commands",
"verboseLogging",
"logLevel",
"syncOnStartup",
"verifyPermissionsOnStartup",
"fasterMembershipChecks",
"automaticallyRedactForReasons",
"protectAllJoinedRooms",
"backgroundDelayMS",
];
const configTemplate = read(); // we use the standard bot config as a template for every provisioned mjolnir.
const unusedKeys = Object.keys(configTemplate).filter(key => !allowedKeys.includes(key));
if (unusedKeys.length > 0) {
LogService.warn("config", "The config provided for provisioned mjolnirs contains keys which are not used by the appservice.", unusedKeys);
}
const config = Config.util.extendDeep(
getDefaultConfig(),
allowedKeys.reduce((existingConfig: any, key: string) => {
return { ...existingConfig, [key]: configTemplate[key as keyof IConfig] }
}, {})
);
config.managementRoom = managementRoomId;
config.protectedRooms = [];
return config;
}

View File

@ -67,7 +67,7 @@ import { initializeSentry, patchMatrixClient } from "./utils";
patchMatrixClient(); patchMatrixClient();
config.RUNTIME.client = client; config.RUNTIME.client = client;
bot = await Mjolnir.setupMjolnirFromConfig(client, config); bot = await Mjolnir.setupMjolnirFromConfig(client, client, config);
} catch (err) { } catch (err) {
console.error(`Failed to setup mjolnir from the config ${config.dataPath}: ${err}`); console.error(`Failed to setup mjolnir from the config ${config.dataPath}: ${err}`);
throw err; throw err;

View File

@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { extractRequestError, LogService, MatrixClient, RoomCreateOptions, UserID } from "matrix-bot-sdk"; import { extractRequestError, LogService, RoomCreateOptions, UserID } from "matrix-bot-sdk";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { ALL_RULE_TYPES, EntityType, ListRule, Recommendation, ROOM_RULE_TYPES, RULE_ROOM, RULE_SERVER, RULE_USER, SERVER_RULE_TYPES, USER_RULE_TYPES } from "./ListRule"; import { ALL_RULE_TYPES, EntityType, ListRule, Recommendation, ROOM_RULE_TYPES, RULE_ROOM, RULE_SERVER, RULE_USER, SERVER_RULE_TYPES, USER_RULE_TYPES } from "./ListRule";
import { MatrixSendClient } from "../MatrixEmitter";
export const SHORTCODE_EVENT_TYPE = "org.matrix.mjolnir.shortcode"; export const SHORTCODE_EVENT_TYPE = "org.matrix.mjolnir.shortcode";
@ -104,7 +105,7 @@ class PolicyList extends EventEmitter {
* @param roomRef A sharable/clickable matrix URL that refers to the room. * @param roomRef A sharable/clickable matrix URL that refers to the room.
* @param client A matrix client that is used to read the state of the room when `updateList` is called. * @param client A matrix client that is used to read the state of the room when `updateList` is called.
*/ */
constructor(public readonly roomId: string, public readonly roomRef: string, private client: MatrixClient) { constructor(public readonly roomId: string, public readonly roomRef: string, private client: MatrixSendClient) {
super(); super();
this.batcher = new UpdateBatcher(this); this.batcher = new UpdateBatcher(this);
} }
@ -118,7 +119,7 @@ class PolicyList extends EventEmitter {
* @returns The room id for the newly created policy list. * @returns The room id for the newly created policy list.
*/ */
public static async createList( public static async createList(
client: MatrixClient, client: MatrixSendClient,
shortcode: string, shortcode: string,
invite: string[], invite: string[],
createRoomOptions: RoomCreateOptions = {} createRoomOptions: RoomCreateOptions = {}

View File

@ -64,7 +64,7 @@ export class ProtectionManager {
*/ */
public async start() { public async start() {
this.mjolnir.reportManager.on("report.new", this.handleReport.bind(this)); this.mjolnir.reportManager.on("report.new", this.handleReport.bind(this));
this.mjolnir.client.on("room.event", this.handleEvent.bind(this)); this.mjolnir.matrixEmitter.on("room.event", this.handleEvent.bind(this));
for (const protection of PROTECTIONS) { for (const protection of PROTECTIONS) {
try { try {
await this.registerProtection(protection); await this.registerProtection(protection);

View File

@ -18,6 +18,7 @@ import { ERROR_KIND_FATAL } from "../ErrorCache";
import { RoomUpdateError } from "../models/RoomUpdateError"; import { RoomUpdateError } from "../models/RoomUpdateError";
import { redactUserMessagesIn } from "../utils"; import { redactUserMessagesIn } from "../utils";
import ManagementRoomOutput from "../ManagementRoomOutput"; import ManagementRoomOutput from "../ManagementRoomOutput";
import { MatrixSendClient } from "../MatrixEmitter";
export interface QueuedRedaction { export interface QueuedRedaction {
/** The room which the redaction will take place in. */ /** The room which the redaction will take place in. */
@ -27,7 +28,7 @@ export interface QueuedRedaction {
* Called by the EventRedactionQueue. * Called by the EventRedactionQueue.
* @param client A MatrixClient to use to carry out the redaction. * @param client A MatrixClient to use to carry out the redaction.
*/ */
redact(client: MatrixClient, managementRoom: ManagementRoomOutput): Promise<void> redact(client: MatrixSendClient, managementRoom: ManagementRoomOutput): Promise<void>
/** /**
* Used to test whether the redaction is the equivalent to another redaction. * Used to test whether the redaction is the equivalent to another redaction.
* @param redaction Another QueuedRedaction to test if this redaction is an equivalent to. * @param redaction Another QueuedRedaction to test if this redaction is an equivalent to.
@ -107,7 +108,7 @@ export class EventRedactionQueue {
* @param limitToRoomId If the roomId is provided, only redactions for that room will be processed. * @param limitToRoomId If the roomId is provided, only redactions for that room will be processed.
* @returns A description of any errors encountered by each QueuedRedaction that was processed. * @returns A description of any errors encountered by each QueuedRedaction that was processed.
*/ */
public async process(client: MatrixClient, managementRoom: ManagementRoomOutput, limitToRoomId?: string): Promise<RoomUpdateError[]> { public async process(client: MatrixSendClient, managementRoom: ManagementRoomOutput, limitToRoomId?: string): Promise<RoomUpdateError[]> {
const errors: RoomUpdateError[] = []; const errors: RoomUpdateError[] = [];
const redact = async (currentBatch: QueuedRedaction[]) => { const redact = async (currentBatch: QueuedRedaction[]) => {
for (const redaction of currentBatch) { for (const redaction of currentBatch) {

View File

@ -79,7 +79,7 @@ export class ReportManager extends EventEmitter {
constructor(public mjolnir: Mjolnir) { constructor(public mjolnir: Mjolnir) {
super(); super();
// Configure bot interactions. // Configure bot interactions.
mjolnir.client.on("room.event", async (roomId, event) => { mjolnir.matrixEmitter.on("room.event", async (roomId, event) => {
try { try {
switch (event["type"]) { switch (event["type"]) {
case "m.reaction": { case "m.reaction": {

View File

@ -17,7 +17,6 @@ limitations under the License.
import { import {
LogLevel, LogLevel,
LogService, LogService,
MatrixClient,
MatrixGlob, MatrixGlob,
getRequestFn, getRequestFn,
setRequestFn, setRequestFn,
@ -29,6 +28,7 @@ import * as _ from '@sentry/tracing'; // Performing the import activates tracing
import ManagementRoomOutput from "./ManagementRoomOutput"; import ManagementRoomOutput from "./ManagementRoomOutput";
import { IConfig } from "./config"; import { IConfig } from "./config";
import { MatrixSendClient } from "./MatrixEmitter";
// Define a few aliases to simplify parsing durations. // Define a few aliases to simplify parsing durations.
@ -81,7 +81,7 @@ export function isTrueJoinEvent(event: any): boolean {
* @param limit The number of messages to redact from most recent first. If the limit is reached then no further messages will be redacted. * @param limit The number of messages to redact from most recent first. If the limit is reached then no further messages will be redacted.
* @param noop Whether to operate in noop mode. * @param noop Whether to operate in noop mode.
*/ */
export async function redactUserMessagesIn(client: MatrixClient, managementRoom: ManagementRoomOutput, userIdOrGlob: string, targetRoomIds: string[], limit = 1000, noop = false) { export async function redactUserMessagesIn(client: MatrixSendClient, managementRoom: ManagementRoomOutput, userIdOrGlob: string, targetRoomIds: string[], limit = 1000, noop = false) {
for (const targetRoomId of targetRoomIds) { for (const targetRoomId of targetRoomIds) {
await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Fetching sent messages for ${userIdOrGlob} in ${targetRoomId} to redact...`, targetRoomId); await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Fetching sent messages for ${userIdOrGlob} in ${targetRoomId} to redact...`, targetRoomId);
@ -101,7 +101,7 @@ export async function redactUserMessagesIn(client: MatrixClient, managementRoom:
/** /**
* Gets all the events sent by a user (or users if using wildcards) in a given room ID, since * Gets all the events sent by a user (or users if using wildcards) in a given room ID, since
* the time they joined. * the time they joined.
* @param {MatrixClient} client The client to use. * @param {MatrixSendClient} client The client to use.
* @param {string} sender The sender. A matrix user id or a wildcard to match multiple senders e.g. *.example.com. * @param {string} sender The sender. A matrix user id or a wildcard to match multiple senders e.g. *.example.com.
* Can also be used to generically search the sender field e.g. *bob* for all events from senders with "bob" in them. * Can also be used to generically search the sender field e.g. *bob* for all events from senders with "bob" in them.
* See `MatrixGlob` in matrix-bot-sdk. * See `MatrixGlob` in matrix-bot-sdk.
@ -114,7 +114,7 @@ export async function redactUserMessagesIn(client: MatrixClient, managementRoom:
* The callback will only be called if there are any relevant events. * The callback will only be called if there are any relevant events.
* @returns {Promise<void>} Resolves when either: the limit has been reached, no relevant events could be found or there is no more timeline to paginate. * @returns {Promise<void>} Resolves when either: the limit has been reached, no relevant events could be found or there is no more timeline to paginate.
*/ */
export async function getMessagesByUserIn(client: MatrixClient, sender: string, roomId: string, limit: number, cb: (events: any[]) => void): Promise<void> { export async function getMessagesByUserIn(client: MatrixSendClient, sender: string, roomId: string, limit: number, cb: (events: any[]) => void): Promise<void> {
const isGlob = sender.includes("*"); const isGlob = sender.includes("*");
const roomEventFilter = { const roomEventFilter = {
rooms: [roomId], rooms: [roomId],

View File

@ -9,6 +9,7 @@ import { Mjolnir } from "../../src/Mjolnir";
import { ALL_RULE_TYPES, Recommendation, RULE_SERVER, RULE_USER, SERVER_RULE_TYPES } from "../../src/models/ListRule"; import { ALL_RULE_TYPES, Recommendation, RULE_SERVER, RULE_USER, SERVER_RULE_TYPES } from "../../src/models/ListRule";
import AccessControlUnit, { Access, EntityAccess } from "../../src/models/AccessControlUnit"; import AccessControlUnit, { Access, EntityAccess } from "../../src/models/AccessControlUnit";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { MatrixSendClient } from "../../src/MatrixEmitter";
/** /**
* Create a policy rule in a policy room. * Create a policy rule in a policy room.
@ -20,7 +21,7 @@ import { randomUUID } from "crypto";
* @param template The template to use for the policy rule event. * @param template The template to use for the policy rule event.
* @returns The event id of the newly created policy rule. * @returns The event id of the newly created policy rule.
*/ */
async function createPolicyRule(client: MatrixClient, policyRoomId: string, policyType: string, entity: string, reason: string, template = { recommendation: 'm.ban' }, stateKey = `rule:${entity}`) { async function createPolicyRule(client: MatrixSendClient, policyRoomId: string, policyType: string, entity: string, reason: string, template = { recommendation: 'm.ban' }, stateKey = `rule:${entity}`) {
return await client.sendStateEvent(policyRoomId, policyType, stateKey, { return await client.sendStateEvent(policyRoomId, policyType, stateKey, {
entity, entity,
reason, reason,
@ -37,7 +38,7 @@ async function createPolicyRule(client: MatrixClient, policyRoomId: string, poli
* @param stateKey The key for the rule. * @param stateKey The key for the rule.
* @returns The event id of the void rule that was created to override the old one. * @returns The event id of the void rule that was created to override the old one.
*/ */
async function removePolicyRule(client: MatrixClient, policyRoomId: string, policyType: string, entity: string, stateKey = `rule:${entity}`) { async function removePolicyRule(client: MatrixSendClient, policyRoomId: string, policyType: string, entity: string, stateKey = `rule:${entity}`) {
return await client.sendStateEvent(policyRoomId, policyType, stateKey, {}); return await client.sendStateEvent(policyRoomId, policyType, stateKey, {});
} }

View File

@ -1,29 +1,30 @@
import { MatrixClient } from "matrix-bot-sdk"; import { MatrixClient } from "matrix-bot-sdk";
import { strict as assert } from "assert"; import { strict as assert } from "assert";
import * as crypto from "crypto"; import * as crypto from "crypto";
import { MatrixEmitter } from "../../../src/MatrixEmitter";
/** /**
* Returns a promise that resolves to the first event replying to the event produced by targetEventThunk. * Returns a promise that resolves to the first event replying to the event produced by targetEventThunk.
* @param client A MatrixClient that is already in the targetRoom. We will use it to listen for the event produced by targetEventThunk. * @param matrix A MatrixEmitter from a MatrixClient that is already in the targetRoom. We will use it to listen for the event produced by targetEventThunk.
* This function assumes that the start() has already been called on the client. * This function assumes that the start() has already been called on the client.
* @param targetRoom The room to listen for the reply in. * @param targetRoom The room to listen for the reply in.
* @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reply. * @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reply.
* @returns The replying event. * @returns The replying event.
*/ */
export async function getFirstReply(client: MatrixClient, targetRoom: string, targetEventThunk: () => Promise<string>): Promise<any> { export async function getFirstReply(matrix: MatrixEmitter, targetRoom: string, targetEventThunk: () => Promise<string>): Promise<any> {
return getNthReply(client, targetRoom, 1, targetEventThunk); return getNthReply(matrix, targetRoom, 1, targetEventThunk);
} }
/** /**
* Returns a promise that resolves to the nth event replying to the event produced by targetEventThunk. * Returns a promise that resolves to the nth event replying to the event produced by targetEventThunk.
* @param client A MatrixClient that is already in the targetRoom. We will use it to listen for the event produced by targetEventThunk. * @param matrix A MatrixEmitter from a MatrixClient that is already in the targetRoom. We will use it to listen for the event produced by targetEventThunk.
* This function assumes that the start() has already been called on the client. * This function assumes that the start() has already been called on the client.
* @param targetRoom The room to listen for the reply in. * @param targetRoom The room to listen for the reply in.
* @param n The number of events to wait for. Must be >= 1. * @param n The number of events to wait for. Must be >= 1.
* @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reply. * @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reply.
* @returns The replying event. * @returns The replying event.
*/ */
export async function getNthReply(client: MatrixClient, targetRoom: string, n: number, targetEventThunk: () => Promise<string>): Promise<any> { export async function getNthReply(matrix: MatrixEmitter, targetRoom: string, n: number, targetEventThunk: () => Promise<string>): Promise<any> {
if (Number.isNaN(n) || !Number.isInteger(n) || n <= 0) { if (Number.isNaN(n) || !Number.isInteger(n) || n <= 0) {
throw new TypeError(`Invalid number of events ${n}`); throw new TypeError(`Invalid number of events ${n}`);
} }
@ -35,7 +36,7 @@ export async function getNthReply(client: MatrixClient, targetRoom: string, n: n
}; };
let targetCb; let targetCb;
try { try {
client.on('room.event', addEvent) matrix.on('room.event', addEvent)
const targetEventId = await targetEventThunk(); const targetEventId = await targetEventThunk();
if (typeof targetEventId !== 'string') { if (typeof targetEventId !== 'string') {
throw new TypeError(); throw new TypeError();
@ -61,12 +62,12 @@ export async function getNthReply(client: MatrixClient, targetRoom: string, n: n
} }
} }
} }
client.on('room.event', targetCb); matrix.on('room.event', targetCb);
}); });
} finally { } finally {
client.removeListener('room.event', addEvent); matrix.removeListener('room.event', addEvent);
if (targetCb) { if (targetCb) {
client.removeListener('room.event', targetCb); matrix.removeListener('room.event', targetCb);
} }
} }
} }
@ -74,14 +75,14 @@ export async function getNthReply(client: MatrixClient, targetRoom: string, n: n
/** /**
* Returns a promise that resolves to an event that is reacting to the event produced by targetEventThunk. * Returns a promise that resolves to an event that is reacting to the event produced by targetEventThunk.
* @param client A MatrixClient that is already in the targetRoom that can be started to listen for the event produced by targetEventThunk. * @param matrix A MatrixEmitter for a MatrixClient that is already in the targetRoom that can be started to listen for the event produced by targetEventThunk.
* This function assumes that the start() has already been called on the client. * This function assumes that the start() has already been called on the client.
* @param targetRoom The room to listen for the reaction in. * @param targetRoom The room to listen for the reaction in.
* @param reactionKey The reaction key to wait for. * @param reactionKey The reaction key to wait for.
* @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reaction. * @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reaction.
* @returns The reaction event. * @returns The reaction event.
*/ */
export async function getFirstReaction(client: MatrixClient, targetRoom: string, reactionKey: string, targetEventThunk: () => Promise<string>): Promise<any> { export async function getFirstReaction(matrix: MatrixEmitter, targetRoom: string, reactionKey: string, targetEventThunk: () => Promise<string>): Promise<any> {
let reactionEvents: any[] = []; let reactionEvents: any[] = [];
const addEvent = function (roomId: string, event: any) { const addEvent = function (roomId: string, event: any) {
if (roomId !== targetRoom) return; if (roomId !== targetRoom) return;
@ -90,7 +91,7 @@ export async function getFirstReaction(client: MatrixClient, targetRoom: string,
}; };
let targetCb; let targetCb;
try { try {
client.on('room.event', addEvent) matrix.on('room.event', addEvent)
const targetEventId = await targetEventThunk(); const targetEventId = await targetEventThunk();
for (let event of reactionEvents) { for (let event of reactionEvents) {
const relates_to = event.content['m.relates_to']; const relates_to = event.content['m.relates_to'];
@ -107,12 +108,12 @@ export async function getFirstReaction(client: MatrixClient, targetRoom: string,
resolve(event) resolve(event)
} }
} }
client.on('room.event', targetCb); matrix.on('room.event', targetCb);
}); });
} finally { } finally {
client.removeListener('room.event', addEvent); matrix.removeListener('room.event', addEvent);
if (targetCb) { if (targetCb) {
client.removeListener('room.event', targetCb); matrix.removeListener('room.event', targetCb);
} }
} }
} }

View File

@ -84,7 +84,7 @@ export async function makeMjolnir(config: IConfig): Promise<Mjolnir> {
await overrideRatelimitForUser(config.homeserverUrl, await client.getUserId()); await overrideRatelimitForUser(config.homeserverUrl, await client.getUserId());
patchMatrixClient(); patchMatrixClient();
await ensureAliasedRoomExists(client, config.managementRoom); await ensureAliasedRoomExists(client, config.managementRoom);
let mj = await Mjolnir.setupMjolnirFromConfig(client, config); let mj = await Mjolnir.setupMjolnirFromConfig(client, client, config);
globalClient = client; globalClient = client;
globalMjolnir = mj; globalMjolnir = mj;
return mj; return mj;

View File

@ -310,7 +310,7 @@ describe("Test: Testing RoomMemberManager", function() {
// Initially, the command should show that same result. // Initially, the command should show that same result.
for (let roomId of roomIds) { for (let roomId of roomIds) {
const reply = await getFirstReply(mjolnir.client, mjolnir.managementRoomId, () => { const reply = await getFirstReply(mjolnir.matrixEmitter, mjolnir.managementRoomId, () => {
const command = `!mjolnir status joins ${roomId}`; const command = `!mjolnir status joins ${roomId}`;
return this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); return this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command });
}); });
@ -328,7 +328,7 @@ describe("Test: Testing RoomMemberManager", function() {
const roomId = roomIds[i]; const roomId = roomIds[i];
const joined = manager.getUsersInRoom(roomId, start, 100); const joined = manager.getUsersInRoom(roomId, start, 100);
assert.equal(joined.length, SAMPLE_SIZE / 2 /* half of the users */ + 1 /* mjolnir */, "We should now see all joined users in the room"); assert.equal(joined.length, SAMPLE_SIZE / 2 /* half of the users */ + 1 /* mjolnir */, "We should now see all joined users in the room");
const reply = await getFirstReply(mjolnir.client, mjolnir.managementRoomId, () => { const reply = await getFirstReply(mjolnir.matrixEmitter, mjolnir.managementRoomId, () => {
const command = `!mjolnir status joins ${roomId}`; const command = `!mjolnir status joins ${roomId}`;
return this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); return this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command });
}); });
@ -361,7 +361,7 @@ describe("Test: Testing RoomMemberManager", function() {
for (let i = 0; i < roomIds.length; ++i) { for (let i = 0; i < roomIds.length; ++i) {
const roomId = roomIds[i]; const roomId = roomIds[i];
const reply = await getFirstReply(mjolnir.client, mjolnir.managementRoomId, () => { const reply = await getFirstReply(mjolnir.matrixEmitter, mjolnir.managementRoomId, () => {
const command = `!mjolnir status joins ${roomId}`; const command = `!mjolnir status joins ${roomId}`;
return this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); return this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command });
}); });
@ -684,7 +684,7 @@ describe("Test: Testing RoomMemberManager", function() {
assert.ok(joined.length >= 2 * SAMPLE_SIZE, `In experiment ${experiment.name}, we should have seen ${2 * SAMPLE_SIZE} users, saw ${joined.length}`); assert.ok(joined.length >= 2 * SAMPLE_SIZE, `In experiment ${experiment.name}, we should have seen ${2 * SAMPLE_SIZE} users, saw ${joined.length}`);
// Run experiment. // Run experiment.
await getNthReply(mjolnir.client, mjolnir.managementRoomId, experiment.n, async () => { await getNthReply(mjolnir.matrixEmitter, mjolnir.managementRoomId, experiment.n, async () => {
const command = experiment.command(roomId, roomAlias); const command = experiment.command(roomId, roomAlias);
let result = await this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); let result = await this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command });
return result; return result;