Files
Draupnir/src/appservice/AppService.ts
T
gnuxie eecb074ac8 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
2024-04-06 20:03:34 +01:00

237 lines
11 KiB
TypeScript

/**
* Copyright (C) 2022 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 { AppServiceRegistration, Bridge, Request, WeakEvent, MatrixUser, Logger, setBridgeVersion, PrometheusMetrics } from "matrix-appservice-bridge";
import { DataStore } from ".//datastore";
import { PgDataStore } from "./postgres/PgDataStore";
import { Api } from "./Api";
import { IConfig } from "./config/config";
import { AccessControl } from "./AccessControl";
import { AppserviceCommandHandler } from "./bot/AppserviceCommandHandler";
import { SOFTWARE_VERSION } from "../config";
import { Registry } from 'prom-client';
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");
/**
* Responsible for setting up listeners and delegating functionality to a matrix-appservice-bridge `Bridge` for
* the entrypoint of the application.
*/
export class MjolnirAppService {
private readonly api: Api;
private readonly commands: AppserviceCommandHandler;
/**
* The constructor is private because we want to ensure intialization steps are followed,
* use `makeMjolnirAppService`.
*/
private constructor(
public readonly config: IConfig,
public readonly bridge: Bridge,
public readonly draupnirManager: AppServiceDraupnirManager,
public readonly accessControl: AccessControl,
private readonly dataStore: DataStore,
private readonly prometheusMetrics: PrometheusMetrics
) {
this.api = new Api(config.homeserver.url, draupnirManager);
this.commands = new AppserviceCommandHandler(this);
}
/**
* Make and initialize the app service from the config, ready to be started.
* @param config The appservice's config, not mjolnir's, see `src/appservice/config`.
* @param dataStore A datastore to persist infomration about the mjolniren to.
* @param registrationFilePath A file path to the registration file to read the namespace and tokens from.
* @returns A new `MjolnirAppService`.
*/
public static async makeMjolnirAppService(config: IConfig, dataStore: DataStore, registrationFilePath: string) {
const bridge = new Bridge({
homeserverUrl: config.homeserver.url,
domain: config.homeserver.domain,
registration: registrationFilePath,
// We lazily initialize the controller to avoid null checks
// It also allows us to combine constructor/initialize logic
// to make the code base much simpler. A small hack to pay for an overall less hacky code base.
controller: {
onUserQuery: () => { throw new Error("Mjolnir uninitialized") },
onEvent: () => { throw new Error("Mjolnir uninitialized") },
},
suppressEcho: false,
disableStores: true,
});
await bridge.initialise();
const accessControlRoom = await resolveRoomReferenceSafe(bridge.getBot().getClient(), config.adminRoom);
if (isError(accessControlRoom)) {
throw accessControlRoom.error;
}
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
setBridgeVersion(SOFTWARE_VERSION);
// Due to the way the tests and this prom library works we need to explicitly create a new one each time.
const prometheus = bridge.getPrometheusMetrics(true, new Registry());
const instanceCountGauge = prometheus.addGauge({
name: "draupnir_instances",
help: "Count of Draupnir Instances",
labels: ["status", "uuid"],
});
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.ok,
dataStore,
prometheus
);
bridge.opts.controller = {
onUserQuery: appService.onUserQuery.bind(appService),
onEvent: appService.onEvent.bind(appService),
};
return appService;
}
/**
* Start the appservice for the end user with the appropriate settings from their config and registration file.
* @param port The port to make the appservice listen for transactions from the homeserver on (usually sourced from the cli).
* @param config The parsed configuration file.
* @param registrationFilePath A path to their homeserver registration file.
*/
public static async run(port: number, config: IConfig, registrationFilePath: string): Promise<MjolnirAppService> {
Logger.configure(config.logging ?? { console: "debug" });
const dataStore = new PgDataStore(config.db.connectionString);
await dataStore.init();
const service = await MjolnirAppService.makeMjolnirAppService(config, dataStore, registrationFilePath);
// The call to `start` MUST happen last. As it needs the datastore, and the mjolnir manager to be initialized before it can process events from the homeserver.
await service.start(port);
return service;
}
public onUserQuery(queriedUser: MatrixUser) {
return {}; // auto-provision users with no additonal data
}
/**
* Handle an individual event pushed by the homeserver to us.
* This function is async (and anything downstream would be anyway), which does mean that events can be processed out of order.
* Not a huge problem for us, but is something to be aware of.
* @param request A matrix-appservice-bridge request encapsulating a Matrix event.
* @param context Additional context for the Matrix event.
*/
public async onEvent(request: Request<WeakEvent>) {
const mxEvent = request.getData();
// Provision a new mjolnir for the invitee when the appservice bot (designated by this.bridge.botUserId) is invited to a room.
// Acts as an alternative to the web api provided for the widget.
if ('m.room.member' === mxEvent.type) {
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.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.
}
try {
// reject the invite to keep the room clean and make sure the invetee doesn't get confused and think this is their mjolnir.
await this.bridge.getBot().getClient().leaveRoom(mxEvent.room_id);
} catch (e: any) {
log.warn("Unable to reject an invite to a room", e);
}
}
}
this.draupnirManager.onEvent(request);
this.commands.handleEvent(mxEvent);
}
/**
* Start the appservice. See `run`.
* @param port The port that the appservice should listen on to receive transactions from the homeserver.
*/
private async start(port: number) {
await this.bridge.getBot().getClient().joinRoom(this.config.adminRoom);
log.info("Starting MjolnirAppService, Matrix-side to listen on port", port);
this.api.start(this.config.webAPI.port);
await this.bridge.listen(port);
this.prometheusMetrics.addAppServicePath(this.bridge);
this.bridge.addAppServicePath({
method: "GET",
path: "/healthz",
authenticate: false,
handler: async (_req, res) => {
res.status(200).send('ok');
}
});
log.info("MjolnirAppService started successfully");
}
/**
* Stop listening to requests from both the homeserver and web api and disconnect from the datastore.
*/
public async close(): Promise<void> {
await this.bridge.close();
await this.dataStore.close();
await this.api.close();
}
/**
* Generate a registration file for a fresh deployment of the appservice.
* Included to satisfy `matrix-appservice-bridge`'s `Cli` utility which allows a registration file to be registered when setting up a deployment of an appservice.
* @param reg Any existing parameters to be included in the registration, to be mutated by this method.
* @param callback To call when the registration has been generated with the final registration.
*/
public static generateRegistration(reg: AppServiceRegistration, callback: (finalRegistration: AppServiceRegistration) => void) {
reg.setId(AppServiceRegistration.generateToken());
reg.setHomeserverToken(AppServiceRegistration.generateToken());
reg.setAppServiceToken(AppServiceRegistration.generateToken());
reg.setSenderLocalpart('draupnir-moderation');
// This is maintained for backwards compatibility with mjolnir4all.
reg.addRegexPattern("users", "@mjolnir_.*", true);
reg.addRegexPattern("users", "@draupnir_.*", true);
reg.setRateLimited(false);
callback(reg);
}
}