introduce draupnir factory and manager.

These are used to create draupnirs and then inform draupnirs of
new events. The draupnir manager is also used to start/stop draupnirs
for the appservice
This commit is contained in:
gnuxie
2024-04-06 20:03:34 +01:00
parent e46e355050
commit eecb074ac8
7 changed files with 398 additions and 417 deletions
+4 -30
View File
@@ -26,43 +26,17 @@ limitations under the License.
*/
import {
MjolnirPolicyRoomsConfig,
PolicyListConfig,
PolicyRoomManager,
ProtectedRoomsConfig,
ResolveRoom,
MjolnirProtectedRoomsConfig,
StandardProtectedRoomsSet,
isError,
RoomStateManager,
MjolnirProtectionsConfig,
MjolnirEnabledProtectionsEvent,
MjolnirEnabledProtectionsEventType,
MatrixRoomID,
MjolnirProtectionSettingsEventType,
StandardSetMembership,
RoomMembershipManager,
SetMembership,
StringUserID,
ProtectedRoomsSet,
MatrixRoomReference,
isStringUserID,
isStringRoomAlias,
isStringRoomID,
SetRoomState,
StandardSetRoomState,
StandardClientRooms,
StandardClientsInRoomMap,
StandardEventDecoder,
DefaultEventDecoder,
} from "matrix-protection-suite";
import {
BotSDKMatrixAccountData,
BotSDKMatrixStateData,
BotSDKMjolnirProtectedRoomsStore,
BotSDKMjolnirWatchedPolicyRoomsStore,
DefaultStateTrackingMeta,
ManagerManager,
MatrixSendClient,
RoomStateManagerFactory,
SafeMatrixEmitter,
@@ -114,13 +88,13 @@ export async function makeDraupnirBotModeFromConfig(
clientProvider,
roomStateManagerFactory
);
const clientRooms = await draupnirFactory.makeDraupnirClientRooms(
const draupnir = await draupnirFactory.makeDraupnir(
clientUserId,
managementRoom.ok,
config
);
if (isError(clientRooms)) {
throw clientRooms.error;
if (isError(draupnir)) {
throw draupnir.error;
}
return draupnir.ok;
}
+35 -16
View File
@@ -1,9 +1,10 @@
import request from "request";
import express from "express";
import * as bodyParser from "body-parser";
import { MjolnirManager } from "./MjolnirManager";
import * as http from "http";
import { Logger } from "matrix-appservice-bridge";
import { AppServiceDraupnirManager } from "./AppServiceDraupnirManager";
import { isError, isStringUserID } from "matrix-protection-suite";
const log = new Logger("Api");
/**
@@ -15,7 +16,7 @@ export class Api {
constructor(
private homeserver: string,
private mjolnirManager: MjolnirManager,
private mjolnirManager: AppServiceDraupnirManager,
) {}
/**
@@ -88,16 +89,18 @@ export class Api {
response.status(401).send("unauthorised");
return;
}
if (!isStringUserID(userId)) {
response.status(400).send("invalid user mxid");
return;
}
const mjolnirId = req.body["mxid"];
if (mjolnirId === undefined) {
if (mjolnirId === undefined || !isStringUserID(mjolnirId)) {
response.status(400).send("invalid request");
return;
}
// TODO: getMjolnir can fail if the ownerId doesn't match the requesting userId.
// https://github.com/matrix-org/mjolnir/issues/408
const mjolnir = this.mjolnirManager.getMjolnir(mjolnirId, userId);
const mjolnir = await this.mjolnirManager.getRunningDraupnir(mjolnirId, userId);
if (mjolnir === undefined) {
response.status(400).send("unknown mjolnir mxid");
return;
@@ -122,8 +125,12 @@ export class Api {
response.status(401).send("unauthorised");
return;
}
if (!isStringUserID(userId)) {
response.status(400).send("invalid user mxid");
return;
}
const existing = this.mjolnirManager.getOwnedMjolnirs(userId)
const existing = this.mjolnirManager.getOwnedDraupnir(userId)
response.status(200).json(existing);
}
@@ -151,12 +158,20 @@ export class Api {
response.status(401).send("unauthorised");
return;
}
if (!isStringUserID(userId)) {
response.status(400).send("invalid user mxid");
return;
}
// TODO: provisionNewMjolnir will throw if it fails...
// https://github.com/matrix-org/mjolnir/issues/408
const [mjolnirId, managementRoom] = await this.mjolnirManager.provisionNewMjolnir(userId);
response.status(200).json({ mxid: mjolnirId, roomId: managementRoom });
const record = await this.mjolnirManager.provisionNewDraupnir(userId);
if (isError(record)) {
response.status(500).send(record.error.message);
return;
}
response.status(200).json({
mxid: this.mjolnirManager.draupnirMXID(record.ok),
roomId: record.ok.management_room
});
}
/**
@@ -177,9 +192,13 @@ export class Api {
response.status(401).send("unauthorised");
return;
}
if (!isStringUserID(userId)) {
response.status(400).send("invalid user mxid");
return;
}
const mjolnirId = req.body["mxid"];
if (mjolnirId === undefined) {
if (mjolnirId === undefined || !isStringUserID(mjolnirId)) {
response.status(400).send("invalid request");
return;
}
@@ -192,14 +211,14 @@ export class Api {
// TODO: getMjolnir can fail if the ownerId doesn't match the requesting userId.
// https://github.com/matrix-org/mjolnir/issues/408
const mjolnir = this.mjolnirManager.getMjolnir(mjolnirId, userId);
const mjolnir = await this.mjolnirManager.getRunningDraupnir(mjolnirId, userId);
if (mjolnir === undefined) {
response.status(400).send("unknown mjolnir mxid");
return;
}
await mjolnir.joinRoom(roomId);
await mjolnir.addProtectedRoom(roomId);
await mjolnir.client.joinRoom(roomId);
await mjolnir.protectedRoomsSet.protectedRoomsConfig.addRoom(roomId);
response.status(200).json({});
}
+23 -10
View File
@@ -26,7 +26,6 @@ limitations under the License.
*/
import { AppServiceRegistration, Bridge, Request, WeakEvent, MatrixUser, Logger, setBridgeVersion, PrometheusMetrics } from "matrix-appservice-bridge";
import { MjolnirManager } from ".//MjolnirManager";
import { DataStore } from ".//datastore";
import { PgDataStore } from "./postgres/PgDataStore";
import { Api } from "./Api";
@@ -35,8 +34,9 @@ import { AccessControl } from "./AccessControl";
import { AppserviceCommandHandler } from "./bot/AppserviceCommandHandler";
import { SOFTWARE_VERSION } from "../config";
import { Registry } from 'prom-client';
import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk";
import { isError } from "matrix-protection-suite";
import { DefaultStateTrackingMeta, RoomStateManagerFactory, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk";
import { DefaultEventDecoder, StandardClientsInRoomMap, StringUserID, isError } from "matrix-protection-suite";
import { AppServiceDraupnirManager } from "./AppServiceDraupnirManager";
const log = new Logger("AppService");
/**
@@ -55,12 +55,12 @@ export class MjolnirAppService {
private constructor(
public readonly config: IConfig,
public readonly bridge: Bridge,
public readonly mjolnirManager: MjolnirManager,
public readonly draupnirManager: AppServiceDraupnirManager,
public readonly accessControl: AccessControl,
private readonly dataStore: DataStore,
private readonly prometheusMetrics: PrometheusMetrics
) {
this.api = new Api(config.homeserver.url, mjolnirManager);
this.api = new Api(config.homeserver.url, draupnirManager);
this.commands = new AppserviceCommandHandler(this);
}
@@ -91,7 +91,19 @@ export class MjolnirAppService {
if (isError(accessControlRoom)) {
throw accessControlRoom.error;
}
const accessControl = await AccessControl.setupAccessControlForRoom(accessControlRoom, bridge);
const clientsInRoomMap = new StandardClientsInRoomMap();
const clientProvider = async (clientUserID: StringUserID) => bridge.getIntent(clientUserID).matrixClient;
const roomStateManagerFactory = new RoomStateManagerFactory(
clientsInRoomMap,
clientProvider,
DefaultEventDecoder,
DefaultStateTrackingMeta
);
const appserviceBotPolicyRoomManager = await roomStateManagerFactory.getPolicyRoomManager(bridge.getBot().getUserId() as StringUserID);
const accessControl = await AccessControl.setupAccessControlForRoom(accessControlRoom.ok, appserviceBotPolicyRoomManager, bridge);
if (isError(accessControl)) {
throw accessControl.error;
}
// Activate /metrics endpoint for Prometheus
// This should happen automatically but in testing this didn't happen in the docker image
@@ -105,12 +117,13 @@ export class MjolnirAppService {
labels: ["status", "uuid"],
});
const mjolnirManager = await MjolnirManager.makeMjolnirManager(dataStore, bridge, accessControl, instanceCountGauge);
const serverName = config.homeserver.domain;
const mjolnirManager = await AppServiceDraupnirManager.makeDraupnirManager(serverName, dataStore, bridge, accessControl.ok, roomStateManagerFactory, instanceCountGauge);
const appService = new MjolnirAppService(
config,
bridge,
mjolnirManager,
accessControl,
accessControl.ok,
dataStore,
prometheus
);
@@ -156,7 +169,7 @@ export class MjolnirAppService {
if ('invite' === mxEvent.content['membership'] && mxEvent.state_key === this.bridge.botUserId) {
log.info(`${mxEvent.sender} has sent an invitation to the appservice bot ${this.bridge.botUserId}, attempting to provision them a mjolnir`);
try {
await this.mjolnirManager.provisionNewMjolnir(mxEvent.sender)
await this.draupnirManager.provisionNewDraupnir(mxEvent.sender as StringUserID)
} catch (e: any) {
log.error(`Failed to provision a mjolnir for ${mxEvent.sender} after they invited ${this.bridge.botUserId}:`, e);
// continue, we still want to reject this invitation.
@@ -169,7 +182,7 @@ export class MjolnirAppService {
}
}
}
this.mjolnirManager.onEvent(request);
this.draupnirManager.onEvent(request);
this.commands.handleEvent(mxEvent);
}
+293
View File
@@ -0,0 +1,293 @@
/**
* Copyright (C) 2022-2024 Gnuxie <Gnuxie@protonmail.com>
* All rights reserved.
*
* This file is modified and is NOT licensed under the Apache License.
* This modified file incorperates work from mjolnir
* https://github.com/matrix-org/mjolnir
* which included the following license notice:
Copyright 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.
*
* However, this file is modified and the modifications in this file
* are NOT distributed, contributed, committed, or licensed under the Apache License.
*/
import { Bridge, Intent, Logger } from "matrix-appservice-bridge";
import { getProvisionedMjolnirConfig } from "../config";
import { MatrixClient } from "matrix-bot-sdk";
import { DataStore, MjolnirRecord } from "./datastore";
import { AccessControl } from "./AccessControl";
import { randomUUID } from "crypto";
import { Gauge } from "prom-client";
import { decrementGaugeValue, incrementGaugeValue } from "../utils";
import { Access, ActionError, ActionException, ActionExceptionKind, ActionResult, MatrixRoomReference, Ok, PropagationType, StringRoomID, StringUserID, Task, isError, isStringRoomID, userLocalpart } from "matrix-protection-suite";
import { Draupnir } from "../Draupnir";
import { RoomStateManagerFactory } from "matrix-protection-suite-for-matrix-bot-sdk";
import { DraupnirFailType, StandardDraupnirManager, UnstartedDraupnir } from "../draupnirfactory/StandardDraupnirManager";
import { DraupnirFactory } from "../draupnirfactory/DraupnirFactory";
const log = new Logger('AppServiceDraupnirManager');
/**
* The DraupnirManager is responsible for:
* * Provisioning new draupnir instances.
* * Starting draupnir when the appservice is brought online.
* * Informing draupnir about new events.
*/
export class AppServiceDraupnirManager {
private readonly baseManager: StandardDraupnirManager;
private constructor(
private readonly serverName: string,
private readonly dataStore: DataStore,
private readonly bridge: Bridge,
private readonly accessControl: AccessControl,
private readonly roomStateManagerFactory: RoomStateManagerFactory,
private readonly instanceCountGauge: Gauge<"status" | "uuid">
) {
const clientProvider = this.bridge.getIntent.bind(this.bridge);
const draupnirFactory = new DraupnirFactory(
clientProvider,
this.roomStateManagerFactory
);
this.baseManager = new StandardDraupnirManager(
draupnirFactory,
roomStateManagerFactory.clientsInRoomMap
);
}
public draupnirMXID(mjolnirRecord: MjolnirRecord): StringUserID {
return `${mjolnirRecord.local_part}:${this.serverName}` as StringUserID;
}
/**
* Create the draupnir manager from the datastore and the access control.
* @param dataStore The data store interface that has the details for provisioned draupnirs.
* @param bridge The bridge abstraction that encapsulates details about the appservice.
* @param accessControl Who has access to the bridge.
* @returns A new mjolnir manager.
*/
public static async makeDraupnirManager(
serverName: string,
dataStore: DataStore,
bridge: Bridge,
accessControl: AccessControl,
roomStateManagerFactory: RoomStateManagerFactory,
instanceCountGauge: Gauge<"status" | "uuid">
): Promise<AppServiceDraupnirManager> {
const draupnirManager = new AppServiceDraupnirManager(serverName, dataStore, bridge, accessControl, roomStateManagerFactory, instanceCountGauge);
await draupnirManager.startDraupnirs(await dataStore.list());
return draupnirManager;
}
/**
* Creates a new mjolnir for a user.
* @param requestingUserID The user that is requesting this mjolnir and who will own it.
* @param managementRoomId An existing matrix room to act as the management room.
* @param client A client for the appservice virtual user that the new mjolnir should use.
* @returns A new managed mjolnir.
*/
public async makeInstance(localPart: string, requestingUserID: StringUserID, managementRoomID: StringRoomID, client: MatrixClient): Promise<ActionResult<Draupnir>> {
const mxid = await client.getUserId() as StringUserID;
const managedDraupnir = await this.baseManager.makeDraupnir(
mxid,
MatrixRoomReference.fromRoomID(managementRoomID),
getProvisionedMjolnirConfig(managementRoomID)
);
if (isError(managedDraupnir)) {
return managedDraupnir;
}
this.baseManager.startDraupnir(mxid);
incrementGaugeValue(this.instanceCountGauge, "offline", localPart);
decrementGaugeValue(this.instanceCountGauge, "disabled", localPart);
incrementGaugeValue(this.instanceCountGauge, "online", localPart);
return managedDraupnir;
}
/**
* Gets a draupnir for the corresponding mxid that is owned by a specific user.
* @param draupnirID The mxid of the draupnir we are trying to get.
* @param ownerID The owner of the draupnir. We ask for it explicitly to not leak access to another user's draupnir.
* @returns The matching managed draupnir instance.
*/
public async getRunningDraupnir(draupnirClientID: StringUserID, ownerID: StringUserID): Promise<Draupnir | undefined> {
const records = await this.dataStore.lookupByOwner(ownerID);
if (records.length === 0) {
return undefined;
}
const associatedRecord = records.find(record => record.local_part === userLocalpart(draupnirClientID));
if (associatedRecord === undefined || associatedRecord.owner !== ownerID) {
return undefined;
}
return this.baseManager.findRunningDraupnir(draupnirClientID);
}
/**
* Find all of the running Draupnir that are owned by this specific user.
* @param ownerID An owner of multiple draupnir.
* @returns Any draupnir that they own.
*/
public async getOwnedDraupnir(ownerID: StringUserID): Promise<StringUserID[]> {
const records = await this.dataStore.lookupByOwner(ownerID);
return records.map(record => this.draupnirMXID(record));
}
/**
* provision a new Draupnir for a matrix user.
* @param requestingUserID The mxid of the user we are creating a Draupnir for.
* @returns The matrix id of the new Draupnir and its management room.
*/
public async provisionNewDraupnir(requestingUserID: StringUserID): Promise<ActionResult<MjolnirRecord>> {
const access = this.accessControl.getUserAccess(requestingUserID);
if (access.outcome !== Access.Allowed) {
return ActionError.Result(`${requestingUserID} tried to provision a draupnir when they do not have access ${access.outcome} ${access.rule?.reason ?? 'no reason specified'}`);
}
const provisionedMjolnirs = await this.dataStore.lookupByOwner(requestingUserID);
if (provisionedMjolnirs.length === 0) {
const mjolnirLocalPart = `draupnir_${randomUUID()}`;
const mjIntent = await this.makeMatrixIntent(mjolnirLocalPart);
const managementRoomID = await mjIntent.matrixClient.createRoom({
preset: 'private_chat',
invite: [requestingUserID],
name: `${requestingUserID}'s Draupnir`,
power_level_content_override: {
users: {
[requestingUserID]: 100,
// Give the mjolnir a higher PL so that can avoid issues with managing the management room.
[await mjIntent.matrixClient.getUserId()]: 101
}
}
});
if (!isStringRoomID(managementRoomID)) {
throw new TypeError(`${managementRoomID} malformed managmentRoomID`);
}
const draupnir = await this.makeInstance(mjolnirLocalPart, requestingUserID, managementRoomID, mjIntent.matrixClient);
if (isError(draupnir)) {
return draupnir;
}
const policyListResult = await createFirstList(draupnir.ok, requestingUserID, "list");
if (isError(policyListResult)) {
return policyListResult;
}
const record = {
local_part: mjolnirLocalPart,
owner: requestingUserID,
management_room: managementRoomID,
} as MjolnirRecord;
await this.dataStore.store(record);
return Ok(record);
} else {
return ActionError.Result(`User: ${requestingUserID} has already provisioned ${provisionedMjolnirs.length} draupnirs.`);
}
}
public getUnstartedDraupnirs(): UnstartedDraupnir[] {
return this.baseManager.getUnstartedDraupnirs();
}
public findUnstartedMjolnir(clientUserID: StringUserID): UnstartedDraupnir | undefined {
return this.baseManager.findUnstartedDraupnir(clientUserID);
}
/**
* Utility that creates a matrix client for a virtual user on our homeserver with the specified loclapart.
* @param localPart The localpart of the virtual user we need a client for.
* @returns A bridge intent with the complete mxid of the virtual user and a MatrixClient.
*/
private async makeMatrixIntent(localPart: string): Promise<Intent> {
const mjIntent = this.bridge.getIntentFromLocalpart(localPart);
await mjIntent.ensureRegistered();
return mjIntent;
}
/**
* Attempt to start a mjolnir, and notify its management room of any failure to start.
* Will be added to `this.unstartedMjolnirs` if we fail to start it AND it is not already running.
* @param mjolnirRecord The record for the mjolnir that we want to start.
*/
public async startDraupnir(mjolnirRecord: MjolnirRecord): Promise<ActionResult<void>> {
const clientUserID = this.draupnirMXID(mjolnirRecord);
if (this.baseManager.isDraupnirListening(clientUserID)) {
throw new TypeError(`${mjolnirRecord.local_part} is already running, we cannot start it.`);
}
const mjIntent = await this.makeMatrixIntent(mjolnirRecord.local_part);
const access = this.accessControl.getUserAccess(mjolnirRecord.owner);
if (access.outcome !== Access.Allowed) {
// Don't await, we don't want to clobber initialization just because we can't tell someone they're no longer allowed.
Task((async () => {
mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your draupnir has been disabled by the administrator: ${access.rule?.reason ?? "no reason supplied"}`);
})());
this.baseManager.reportUnstartedDraupnir(DraupnirFailType.Unauthorized, access.outcome, clientUserID);
decrementGaugeValue(this.instanceCountGauge, "online", mjolnirRecord.local_part);
incrementGaugeValue(this.instanceCountGauge, "disabled", mjolnirRecord.local_part);
return ActionError.Result(`Tried to start a draupnir that has been disabled by the administrator: ${access.rule?.reason ?? 'no reason supplied'}`);
} else {
const startResult = await this.makeInstance(
mjolnirRecord.local_part,
mjolnirRecord.owner,
mjolnirRecord.management_room,
mjIntent.matrixClient,
).catch((e) => {
log.error(`Could not start mjolnir ${mjolnirRecord.local_part} for ${mjolnirRecord.owner}:`, e);
this.baseManager.reportUnstartedDraupnir(DraupnirFailType.StartError, e, clientUserID);
return ActionException.Result(`Could not start draupnir ${clientUserID} for owner ${mjolnirRecord.owner}`, {
exception: e,
exceptionKind: ActionExceptionKind.Unknown
})
});
if (isError(startResult)) {
// Don't await, we don't want to clobber initialization if this fails.
Task((async () => {
mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your draupnir could not be started. Please alert the administrator`);
})());
decrementGaugeValue(this.instanceCountGauge, "online", mjolnirRecord.local_part);
incrementGaugeValue(this.instanceCountGauge, "offline", mjolnirRecord.local_part);
return startResult;
}
return Ok(undefined);
}
}
// TODO: We need to check that an owner still has access to the appservice each time they send a command to the mjolnir or use the web api.
// https://github.com/matrix-org/mjolnir/issues/410
/**
* Used at startup to create all the ManagedMjolnir instances and start them so that they will respond to users.
*/
public async startDraupnirs(mjolnirRecords: MjolnirRecord[]): Promise<void> {
for (const mjolnirRecord of mjolnirRecords) {
await this.startDraupnir(mjolnirRecord);
}
}
}
async function createFirstList(draupnir: Draupnir, draupnirOwnerID: StringUserID, shortcode: string): Promise<ActionResult<void>> {
const policyRoom = await draupnir.policyRoomManager.createPolicyRoom(
shortcode,
[draupnirOwnerID],
{ name: `${draupnirOwnerID}'s policy room` }
);
if (isError(policyRoom)) {
throw policyRoom.error;
}
const addRoomResult = await draupnir.protectedRoomsSet.protectedRoomsConfig.addRoom(policyRoom.ok);
if (isError(addRoomResult)) {
return addRoomResult;
}
return await draupnir.protectedRoomsSet.issuerManager.watchList(PropagationType.Direct, policyRoom.ok, {});
}
-350
View File
@@ -1,350 +0,0 @@
import { Request, WeakEvent, Bridge, Intent, Logger } from "matrix-appservice-bridge";
import { getProvisionedMjolnirConfig } from "../config";
import { MatrixClient, UserID } from "matrix-bot-sdk";
import { DataStore, MjolnirRecord } from "./datastore";
import { AccessControl } from "./AccessControl";
import { randomUUID } from "crypto";
import EventEmitter from "events";
import { Gauge } from "prom-client";
import { decrementGaugeValue, incrementGaugeValue } from "../utils";
import { makeDraupnirBotModeFromConfig } from "../DraupnirBotMode";
import { Access, MatrixRoomID, PropagationType, StringRoomID, StringUserID, isError, isStringRoomID } from "matrix-protection-suite";
import { Draupnir } from "../Draupnir";
import { MatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk";
const log = new Logger('MjolnirManager');
// FIXME: AAAAAAAAaaaaaaaaaaaaa there's some inconsistency between "mjolnir id" "mjolnirRecord.localpart" and "user if of the mjolnir"
// all over this file.
/**
* The MjolnirManager is responsible for:
* * Provisioning new mjolnir instances.
* * Starting mjolnirs when the appservice is brought online.
* * Informing mjolnirs about new events.
*/
export class MjolnirManager {
private readonly mjolnirs: Map</*the user id of the mjolnir*/string, ManagedDraupnir> = new Map();
private readonly unstartedMjolnirs: Map</** user id of the mjolnir */string, UnstartedMjolnir> = new Map();
private constructor(
private readonly dataStore: DataStore,
private readonly bridge: Bridge,
private readonly accessControl: AccessControl,
private readonly instanceCountGauge: Gauge<"status" | "uuid">
) {
}
/**
* Create the mjolnir manager from the datastore and the access control.
* @param dataStore The data store interface that has the details for provisioned mjolnirs.
* @param bridge The bridge abstraction that encapsulates details about the appservice.
* @param accessControl Who has access to the bridge.
* @returns A new mjolnir manager.
*/
public static async makeMjolnirManager(dataStore: DataStore, bridge: Bridge, accessControl: AccessControl, instanceCountGauge: Gauge<"status" | "uuid">): Promise<MjolnirManager> {
const mjolnirManager = new MjolnirManager(dataStore, bridge, accessControl, instanceCountGauge);
await mjolnirManager.startMjolnirs(await dataStore.list());
return mjolnirManager;
}
/**
* Creates a new mjolnir for a user.
* @param requestingUserID The user that is requesting this mjolnir and who will own it.
* @param managementRoomId An existing matrix room to act as the management room.
* @param client A client for the appservice virtual user that the new mjolnir should use.
* @returns A new managed mjolnir.
*/
public async makeInstance(localPart: string, requestingUserID: StringUserID, managementRoomID: StringRoomID, client: MatrixClient): Promise<ManagedDraupnir> {
const mxid = await client.getUserId();
const intentListener = new MatrixIntentListener(mxid);
const managedMjolnir = new ManagedDraupnir(
requestingUserID,
await makeDraupnirBotModeFromConfig(
client,
intentListener,
getProvisionedMjolnirConfig(managementRoomID)
),
intentListener,
);
await managedMjolnir.start();
this.mjolnirs.set(mxid, managedMjolnir);
this.unstartedMjolnirs.delete(mxid);
incrementGaugeValue(this.instanceCountGauge, "offline", localPart);
decrementGaugeValue(this.instanceCountGauge, "disabled", localPart);
incrementGaugeValue(this.instanceCountGauge, "online", localPart);
return managedMjolnir;
}
/**
* Gets a mjolnir for the corresponding mxid that is owned by a specific user.
* @param mjolnirID The mxid of the mjolnir we are trying to get.
* @param ownerID The owner of the mjolnir. We ask for it explicitly to not leak access to another user's mjolnir.
* @returns The matching managed mjolnir instance.
*/
public getMjolnir(mjolnirID: StringUserID, ownerID: StringUserID): ManagedDraupnir | undefined {
const mjolnir = this.mjolnirs.get(mjolnirID);
if (mjolnir) {
if (mjolnir.ownerID !== ownerID) {
throw new Error(`${mjolnirID} is owned by a different user to ${ownerID}`);
} else {
return mjolnir;
}
} else {
return undefined;
}
}
/**
* Find all of the mjolnirs that are owned by this specific user.
* @param ownerID An owner of multiple mjolnirs.
* @returns Any mjolnirs that they own.
*/
public getOwnedMjolnirs(ownerID: StringUserID): ManagedDraupnir[] {
// TODO we need to use the database for this but also provide the utility
// for going from a MjolnirRecord to a ManagedMjolnir.
// https://github.com/matrix-org/mjolnir/issues/409
return [...this.mjolnirs.values()].filter(mjolnir => mjolnir.ownerID !== ownerID);
}
/**
* Listener that should be setup and called by `MjolnirAppService` while listening to the bridge abstraction provided by matrix-appservice-bridge.
*/
public onEvent(request: Request<WeakEvent>) {
// TODO We need a way to map a room id (that the event is from) to a set of managed mjolnirs that should be informed.
// https://github.com/matrix-org/mjolnir/issues/412
[...this.mjolnirs.values()].forEach((mj: ManagedDraupnir) => mj.onEvent(request));
}
/**
* provision a new mjolnir for a matrix user.
* @param requestingUserID The mxid of the user we are creating a mjolnir for.
* @returns The matrix id of the new mjolnir and its management room.
*/
public async provisionNewMjolnir(requestingUserID: StringUserID): Promise<[StringUserID, StringRoomID]> {
const access = this.accessControl.getUserAccess(requestingUserID);
if (access.outcome !== Access.Allowed) {
throw new Error(`${requestingUserID} tried to provision a mjolnir when they do not have access ${access.outcome} ${access.rule?.reason ?? 'no reason specified'}`);
}
const provisionedMjolnirs = await this.dataStore.lookupByOwner(requestingUserID);
if (provisionedMjolnirs.length === 0) {
const mjolnirLocalPart = `draupnir_${randomUUID()}`;
const mjIntent = await this.makeMatrixIntent(mjolnirLocalPart);
const managementRoomID = await mjIntent.matrixClient.createRoom({
preset: 'private_chat',
invite: [requestingUserID],
name: `${requestingUserID}'s Draupnir`,
power_level_content_override: {
users: {
[requestingUserID]: 100,
// Give the mjolnir a higher PL so that can avoid issues with managing the management room.
[await mjIntent.matrixClient.getUserId()]: 101
}
}
});
if (!isStringRoomID(managementRoomID)) {
throw new TypeError(`${managementRoomID} malformed managmentRoomID`);
}
const mjolnir = await this.makeInstance(mjolnirLocalPart, requestingUserID, managementRoomID, mjIntent.matrixClient);
await mjolnir.createFirstList(requestingUserID, "list");
await this.dataStore.store({
local_part: mjolnirLocalPart,
owner: requestingUserID,
management_room: managementRoomID,
});
return [mjIntent.userId as StringUserID, managementRoomID as StringRoomID];
} else {
throw new Error(`User: ${requestingUserID} has already provisioned ${provisionedMjolnirs.length} Draupnirs.`);
}
}
public reportUnstartedMjolnir(code: UnstartedMjolnir.FailCode, cause: any, mjolnirRecord: MjolnirRecord, mxid: string): void {
this.unstartedMjolnirs.set(mjolnirRecord.local_part, new UnstartedMjolnir(mjolnirRecord, new UserID(mxid), code, cause));
}
public getUnstartedMjolnirs(): UnstartedMjolnir[] {
return [...this.unstartedMjolnirs.values()];
}
public findUnstartedMjolnir(localPart: string): UnstartedMjolnir | undefined {
return [...this.unstartedMjolnirs.values()].find(unstarted => unstarted.mjolnirRecord.local_part === localPart);
}
/**
* Utility that creates a matrix client for a virtual user on our homeserver with the specified loclapart.
* @param localPart The localpart of the virtual user we need a client for.
* @returns A bridge intent with the complete mxid of the virtual user and a MatrixClient.
*/
private async makeMatrixIntent(localPart: string): Promise<Intent> {
const mjIntent = this.bridge.getIntentFromLocalpart(localPart);
await mjIntent.ensureRegistered();
return mjIntent;
}
/**
* Attempt to start a mjolnir, and notify its management room of any failure to start.
* Will be added to `this.unstartedMjolnirs` if we fail to start it AND it is not already running.
* @param mjolnirRecord The record for the mjolnir that we want to start.
*/
public async startMjolnir(mjolnirRecord: MjolnirRecord): Promise<void> {
// if a mjolnir is in `this.mjonirs` it is started, as if it is present, it is going to be given Matrix events.
if (this.mjolnirs.has(mjolnirRecord.local_part)) {
throw new TypeError(`${mjolnirRecord.local_part} is already running, we cannot start it.`);
}
const mjIntent = await this.makeMatrixIntent(mjolnirRecord.local_part);
const access = this.accessControl.getUserAccess(mjolnirRecord.owner);
if (access.outcome !== Access.Allowed) {
// Don't await, we don't want to clobber initialization just because we can't tell someone they're no longer allowed.
mjIntent.matrixClient.sendNotice(mjolnirRecord.management_room, `Your mjolnir has been disabled by the administrator: ${access.rule?.reason ?? "no reason supplied"}`);
this.reportUnstartedMjolnir(UnstartedMjolnir.FailCode.Unauthorized, access.outcome, mjolnirRecord, mjIntent.userId);
decrementGaugeValue(this.instanceCountGauge, "online", mjolnirRecord.local_part);
incrementGaugeValue(this.instanceCountGauge, "disabled", mjolnirRecord.local_part);
} else {
await this.makeInstance(
mjolnirRecord.local_part,
mjolnirRecord.owner,
mjolnirRecord.management_room,
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`);
this.reportUnstartedMjolnir(UnstartedMjolnir.FailCode.StartError, e, mjolnirRecord, mjIntent.userId);
decrementGaugeValue(this.instanceCountGauge, "online", mjolnirRecord.local_part);
incrementGaugeValue(this.instanceCountGauge, "offline", mjolnirRecord.local_part);
});
}
}
// TODO: We need to check that an owner still has access to the appservice each time they send a command to the mjolnir or use the web api.
// https://github.com/matrix-org/mjolnir/issues/410
/**
* Used at startup to create all the ManagedMjolnir instances and start them so that they will respond to users.
*/
public async startMjolnirs(mjolnirRecords: MjolnirRecord[]): Promise<void> {
for (const mjolnirRecord of mjolnirRecords) {
await this.startMjolnir(mjolnirRecord);
}
}
}
// FIXME: This isn't acceptable really, we need a managerManager that
// shares the same caches internally but uses different clients to do things.
// (Within MPS).
export class ManagedDraupnir {
public constructor(
public readonly ownerID: StringUserID,
private readonly draupnir: Draupnir,
private readonly matrixEmitter: MatrixIntentListener,
) { }
public async onEvent(request: Request<WeakEvent>) {
this.matrixEmitter.handleEvent(request.getData());
}
public async joinRoom(roomId: string) {
await this.draupnir.client.joinRoom(roomId);
}
public async addProtectedRoom(room: MatrixRoomID) {
await this.draupnir.protectedRoomsSet.protectedRoomsConfig.addRoom(room);
}
public async createFirstList(draupnirOwnerID: StringUserID, shortcode: string) {
const policyRoom = await this.draupnir.managerManager.policyRoomManager.createPolicyRoom(
shortcode,
[draupnirOwnerID],
{ name: `${draupnirOwnerID}'s policy room` }
);
if (isError(policyRoom)) {
throw policyRoom.error;
}
await this.addProtectedRoom(policyRoom.ok);
return await this.draupnir.protectedRoomsSet.issuerManager.watchList(PropagationType.Direct, policyRoom.ok, {});
}
public get managementRoomID(): StringRoomID {
return this.draupnir.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.draupnir.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.
}
}
export class UnstartedMjolnir {
constructor(
public readonly mjolnirRecord: MjolnirRecord,
public readonly mxid: UserID,
public readonly failCode: UnstartedMjolnir.FailCode,
public readonly cause: any,
) {
}
}
export namespace UnstartedMjolnir {
export enum FailCode {
Unauthorized = "Unauthorized",
StartError = "StartError",
}
}
+2 -2
View File
@@ -29,7 +29,7 @@ const listUnstarted = defineInterfaceCommand<AppserviceBaseExecutor>({
table: "appservice bot",
parameters: parameters([]),
command: async function () {
return Ok(this.appservice.mjolnirManager.getUnstartedMjolnirs());
return Ok(this.appservice.draupnirManager.getUnstartedMjolnirs());
},
summary: "List any Mjolnir that failed to start."
});
@@ -81,7 +81,7 @@ const restart = defineInterfaceCommand<AppserviceBaseExecutor>({
}
]),
command: async function (this, _keywords, mjolnirId: UserID): Promise<ActionResult<true>> {
const mjolnirManager = this.appservice.mjolnirManager;
const mjolnirManager = this.appservice.draupnirManager;
const mjolnir = mjolnirManager.findUnstartedMjolnir(mjolnirId.localpart);
if (mjolnir?.mjolnirRecord === undefined) {
return ActionError.Result(`We can't find the unstarted mjolnir ${mjolnirId}, is it running?`);
+41 -9
View File
@@ -1,5 +1,5 @@
/**
* Copyright (C) 2022-2023 Gnuxie <Gnuxie@protonmail.com>
* Copyright (C) 2022-2024 Gnuxie <Gnuxie@protonmail.com>
* All rights reserved.
*
* This file is modified and is NOT licensed under the Apache License.
@@ -25,19 +25,19 @@ limitations under the License.
* are NOT distributed, contributed, committed, or licensed under the Apache License.
*/
import { ActionError, ActionResult, MatrixRoomID, StandardClientsInRoomMap, StringUserID, isError } from "matrix-protection-suite";
import { ActionError, ActionResult, ClientsInRoomMap, MatrixRoomID, RoomEvent, StringRoomID, StringUserID, isError } from "matrix-protection-suite";
import { IConfig } from "../config";
import { DraupnirFactory } from "./DraupnirFactory";
import { Draupnir } from "../Draupnir";
export abstract class StandardDraupnirManager {
export class StandardDraupnirManager {
private readonly readyDraupnirs = new Map<StringUserID, Draupnir>();
private readonly listeningDraupnirs = new Map<StringUserID, Draupnir>();
private readonly clientsInRooms = new StandardClientsInRoomMap();
private readonly failedDraupnirs = new Map<StringUserID, UnstartedDraupnir>();
public constructor(
protected readonly draupnirFactory: DraupnirFactory
protected readonly draupnirFactory: DraupnirFactory,
private readonly clientsInRooms: ClientsInRoomMap
) {
// nothing to do.
}
@@ -58,11 +58,11 @@ export abstract class StandardDraupnirManager {
return ActionError.Result(`There is a draupnir for ${clientUserID} already running`);
}
if (isError(draupnir)) {
this.failedDraupnirs.set(clientUserID, new UnstartedDraupnir(
clientUserID,
this.reportUnstartedDraupnir(
DraupnirFailType.InitializationError,
draupnir.error
))
draupnir.error,
clientUserID
);
return draupnir;
}
this.readyDraupnirs.set(clientUserID, draupnir.ok);
@@ -70,6 +70,34 @@ export abstract class StandardDraupnirManager {
return draupnir;
}
public isDraupnirReady(draupnirClientID: StringUserID): boolean {
return this.readyDraupnirs.has(draupnirClientID);
}
public isDraupnirListening(draupnirClientID: StringUserID): boolean {
return this.listeningDraupnirs.has(draupnirClientID);
}
public isDraupnirFailed(draupnirClientID: StringUserID): boolean {
return this.failedDraupnirs.has(draupnirClientID);
}
public reportUnstartedDraupnir(failType: DraupnirFailType, cause: unknown, draupnirClientID: StringUserID): void {
this.failedDraupnirs.set(draupnirClientID, new UnstartedDraupnir(draupnirClientID, failType, cause));
}
public getUnstartedDraupnirs(): UnstartedDraupnir[] {
return [...this.failedDraupnirs.values()];
}
public findUnstartedDraupnir(draupnirClientID: StringUserID): UnstartedDraupnir | undefined {
return this.failedDraupnirs.get(draupnirClientID);
}
public findRunningDraupnir(draupnirClientID: StringUserID): Draupnir | undefined {
return this.listeningDraupnirs.get(draupnirClientID);
}
public startDraupnir(
clientUserID: StringUserID
): void {
@@ -94,6 +122,10 @@ export abstract class StandardDraupnirManager {
this.readyDraupnirs.set(clientUserID, draupnir);
}
}
public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void {
this.clientsInRooms.handleTimelineEvent(roomID, event);
}
}
export class UnstartedDraupnir {