Files
Draupnir/apps/draupnir/src/Draupnir.ts
T
Gnuxie b63ab3ce89
Docker Hub - Develop / docker-latest (push) Canceled after 0s
GHCR - Development Branches / ghcr-publish (push) Canceled after 0s
Tests / Build & Lint (push) Canceled after 0s
Tests / Unit tests (push) Canceled after 0s
Tests / Integration tests (push) Canceled after 0s
Tests / Application Service Integration tests (push) Canceled after 0s
Validate Docker Build / validate-docker-build (push) Canceled after 0s
Remove the aliases for the matrix-protection-suite packages. (#1162)
* Remove old matrix-protection-suite package aliases.

This was leading to issues with monorepo package management.
https://github.com/the-draupnir-project/Draupnir/issues/1124

* Fix badly imported symbols from packages that already export them.

* Cleanup build command arguments.

There was a deprecation warning for short workspaces flag.
2026-07-18 12:12:57 +00:00

476 lines
16 KiB
TypeScript

// Copyright 2022 - 2023 Gnuxie <Gnuxie@protonmail.com>
// Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: Apache-2.0
//
// SPDX-FileAttributionText: <text>
// This modified file incorporates work from mjolnir
// https://github.com/matrix-org/mjolnir
// </text>
import {
ActionResult,
Client,
ClientPlatform,
ClientRooms,
EventReport,
LoggableConfigTracker,
Logger,
Membership,
MembershipEvent,
Ok,
PolicyRoomManager,
ProtectedRoomsSet,
RoomEvent,
RoomMembershipManager,
RoomMembershipRevisionIssuer,
RoomStateManager,
Task,
Value,
isError,
} from "@the-draupnir-project/matrix-protection-suite";
import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue";
import { ThrottlingQueue } from "./queues/ThrottlingQueue";
import ManagementRoomOutput from "./managementroom/ManagementRoomOutput";
import { ReportPoller } from "./report/ReportPoller";
import { StandardReportManager } from "./report/ReportManager";
import {
MatrixSendClient,
SynapseAdminClient,
} from "@the-draupnir-project/matrix-protection-suite-for-matrix-bot-sdk";
import { IConfig } from "./config";
import { LogLevel } from "@vector-im/matrix-bot-sdk";
import { RendererMessageCollector } from "./capabilities/RendererMessageCollector";
import { DraupnirRendererMessageCollector } from "./capabilities/DraupnirRendererMessageCollector";
import { renderProtectionFailedToStart } from "./protections/ProtectedRoomsSetRenderers";
import { draupnirStatusInfo, renderStatusInfo } from "./commands/StatusCommand";
import {
StringRoomID,
StringUserID,
MatrixRoomID,
isStringRoomID,
isStringRoomAlias,
MatrixRoomReference,
userServerName,
} from "@the-draupnir-project/matrix-basic-types";
import {
makeDraupnirCommandDispatcher,
makeDraupnirJSCommandDispatcher,
} from "./commands/DraupnirCommandDispatcher";
import { SafeModeToggle } from "./safemode/SafeModeToggle";
import { makeCommandDispatcherTimelineListener } from "./safemode/ManagementRoom";
import {
BasicInvocationInformation,
JSInterfaceCommandDispatcher,
Presentation,
StandardPresentationArgumentStream,
} from "@the-draupnir-project/interface-manager";
import { ManagementRoomDetail } from "./managementroom/ManagementRoomDetail";
import { SynapseHttpAntispam } from "./webapis/SynapseHTTPAntispam/SynapseHttpAntispam";
import { DraupnirStores } from "./backingstore/DraupnirStores";
import { HomeserverUserPurgingDeactivate } from "./protections/HomeserverUserPolicyApplication/HomeserverUserPurgingDeactivate";
import {
ARGUMENT_PROMPT_LISTENER,
COMMAND_CONFIRMATION_LISTENER,
DEFAUILT_ARGUMENT_PROMPT_LISTENER,
makeConfirmationPromptListener,
makeListenerForArgumentPrompt,
makeListenerForPromptDefault,
MatrixAdaptorContext,
MatrixReactionHandler,
sendMatrixEventsFromDeadDocument,
} from "@the-draupnir-project/mps-interface-adaptor";
import { TimelineRedactionQueue } from "./queues/TimelineRedactionQueue";
import { ResultError } from "@gnuxie/typescript-result";
const log = new Logger("Draupnir");
// webAPIS should not be included on the Draupnir class.
// That should be managed elsewhere.
// It's not actually relevant to the Draupnir instance and it only was connected
// to Draupnir because it needs to be started after Draupnir started and not before.
// And giving it to the class was a dumb easy way of doing that.
export class Draupnir implements Client, MatrixAdaptorContext {
/**
* This is for users who are not listed on a watchlist,
* but have been flagged by the automatic spam detection as suispicous
*/
public unlistedUserRedactionQueue = new UnlistedUserRedactionQueue();
private readonly commandDispatcher: ReturnType<
typeof makeDraupnirCommandDispatcher
>;
public taskQueue: ThrottlingQueue;
/**
* Reporting back to the management room.
*/
public readonly managementRoomOutput: ManagementRoomOutput;
/*
* Config-enabled polling of reports in Synapse, so Draupnir can react to reports
*/
private reportPoller?: ReportPoller;
/**
* Handle user reports from the homeserver.
* FIXME: ReportManager should be a protection.
*/
public readonly reportManager: StandardReportManager;
public readonly reactionHandler: MatrixReactionHandler;
public readonly purgingDeactivate:
| HomeserverUserPurgingDeactivate
| undefined;
private readonly timelineEventListener = this.handleTimelineEvent.bind(this);
public readonly capabilityMessageRenderer: RendererMessageCollector;
private readonly commandDispatcherTimelineListener: ReturnType<
typeof makeCommandDispatcherTimelineListener
>;
private readonly JSInterfaceDispatcher: JSInterfaceCommandDispatcher<BasicInvocationInformation>;
public readonly timelineRedactionQueue: TimelineRedactionQueue;
private constructor(
public readonly client: MatrixSendClient,
public readonly clientUserID: StringUserID,
public clientDisplayName: string,
public readonly clientPlatform: ClientPlatform,
public readonly managementRoomDetail: ManagementRoomDetail,
public readonly clientRooms: ClientRooms,
public readonly config: IConfig,
public readonly protectedRoomsSet: ProtectedRoomsSet,
public readonly roomStateManager: RoomStateManager,
public readonly policyRoomManager: PolicyRoomManager,
public readonly roomMembershipManager: RoomMembershipManager,
public readonly loggableConfigTracker: LoggableConfigTracker,
/** Draupnir has a feature where you can choose to accept invitations from a space and not just the management room. */
public readonly acceptInvitesFromRoom: MatrixRoomID,
public readonly acceptInvitesFromRoomIssuer: RoomMembershipRevisionIssuer,
public readonly safeModeToggle: SafeModeToggle,
public readonly stores: DraupnirStores,
public readonly synapseAdminClient: SynapseAdminClient | undefined,
public readonly synapseHTTPAntispam: SynapseHttpAntispam | undefined
) {
this.commandDispatcher = makeDraupnirCommandDispatcher(this);
this.purgingDeactivate =
this.synapseAdminClient && this.stores.userRestrictionAuditLog
? new HomeserverUserPurgingDeactivate(
this.synapseAdminClient,
this.stores.userRestrictionAuditLog
)
: undefined;
this.commandDispatcherTimelineListener =
makeCommandDispatcherTimelineListener(
this.managementRoom,
this.client,
this.commandDispatcher
);
this.JSInterfaceDispatcher = makeDraupnirJSCommandDispatcher(this);
this.timelineRedactionQueue = new TimelineRedactionQueue(
this.clientPlatform.toRoomMessages(),
this.clientPlatform.toRoomEventRedacter()
);
this.managementRoomOutput = new ManagementRoomOutput(
this.managementRoomDetail,
this.clientUserID,
this.client,
this.config
);
this.taskQueue = new ThrottlingQueue(
this.managementRoomOutput,
config.backgroundDelayMS
);
this.reactionHandler = new MatrixReactionHandler(
this.managementRoom.toRoomIDOrAlias(),
clientUserID,
clientPlatform
);
this.reportManager = new StandardReportManager(this);
if (config.pollReports) {
this.reportPoller = new ReportPoller(this, this.reportManager);
}
this.reactionHandler.on(
ARGUMENT_PROMPT_LISTENER,
makeListenerForArgumentPrompt(
this.commandRoomID,
this.commandDispatcher,
this.reactionHandler
)
);
this.reactionHandler.on(
DEFAUILT_ARGUMENT_PROMPT_LISTENER,
makeListenerForPromptDefault(
this.commandRoomID,
this.commandDispatcher,
this.reactionHandler
)
);
this.reactionHandler.on(
COMMAND_CONFIRMATION_LISTENER,
makeConfirmationPromptListener(
this.commandRoomID,
this.commandDispatcher,
this.reactionHandler
)
);
this.capabilityMessageRenderer = new DraupnirRendererMessageCollector(
this.clientPlatform.toRoomMessageSender(),
this.managementRoomID
);
}
public static async makeDraupnirBot(
client: MatrixSendClient,
clientUserID: StringUserID,
clientDisplayName: string,
clientPlatform: ClientPlatform,
managementRoomDetail: ManagementRoomDetail,
clientRooms: ClientRooms,
protectedRoomsSet: ProtectedRoomsSet,
roomStateManager: RoomStateManager,
policyRoomManager: PolicyRoomManager,
roomMembershipManager: RoomMembershipManager,
config: IConfig,
loggableConfigTracker: LoggableConfigTracker,
safeModeToggle: SafeModeToggle,
stores: DraupnirStores,
synapseHTTPAntispam: SynapseHttpAntispam | undefined
): Promise<ActionResult<Draupnir>> {
const acceptInvitesFromRoom = await (async () => {
if (config.autojoinOnlyIfManager) {
return Ok(managementRoomDetail.managementRoom);
} else {
if (config.acceptInvitesFromSpace === undefined) {
throw new TypeError(
`You cannot leave config.acceptInvitesFromSpace undefined if you have disabled config.autojoinOnlyIfManager`
);
}
const room = (() => {
if (
isStringRoomID(config.acceptInvitesFromSpace) ||
isStringRoomAlias(config.acceptInvitesFromSpace)
) {
return config.acceptInvitesFromSpace;
} else {
const parseResult = MatrixRoomReference.fromPermalink(
config.acceptInvitesFromSpace
);
if (isError(parseResult)) {
throw new TypeError(
`config.acceptInvitesFromSpace: ${config.acceptInvitesFromSpace} needs to be a room id, alias or permalink`
);
}
return parseResult.ok;
}
})();
return await clientPlatform.toRoomJoiner().joinRoom(room);
}
})();
if (isError(acceptInvitesFromRoom)) {
return acceptInvitesFromRoom.elaborate(
"Unable to join the space from config.acceptInvitesFromSpace"
);
}
const acceptInvitesFromRoomIssuer =
await roomMembershipManager.getRoomMembershipRevisionIssuer(
acceptInvitesFromRoom.ok
);
if (isError(acceptInvitesFromRoomIssuer)) {
return acceptInvitesFromRoomIssuer;
}
const draupnir = new Draupnir(
client,
clientUserID,
clientDisplayName,
clientPlatform,
managementRoomDetail,
clientRooms,
config,
protectedRoomsSet,
roomStateManager,
policyRoomManager,
roomMembershipManager,
loggableConfigTracker,
acceptInvitesFromRoom.ok,
acceptInvitesFromRoomIssuer.ok,
safeModeToggle,
stores,
new SynapseAdminClient(client, clientUserID),
synapseHTTPAntispam
);
const loadResult = await protectedRoomsSet.protections.loadProtections(
protectedRoomsSet,
draupnir,
(error, protectionName, description) =>
renderProtectionFailedToStart(
clientPlatform.toRoomMessageSender(),
managementRoomDetail.managementRoomID,
error,
protectionName,
description
)
);
if (isError(loadResult)) {
return loadResult;
}
// we need to make sure that we are protecting the management room so we
// have immediate access to its membership (for accepting invitations).
const managementRoomProtectResult =
await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom(
managementRoomDetail.managementRoom
);
if (isError(managementRoomProtectResult)) {
return managementRoomProtectResult;
}
// Check that Draupnir has the ability to send state events to the management
// room. Which is important because Draupnir users state events to persist
// protection settings, including the draupnir news feed position.
if (!managementRoomDetail.isDraupnirUserPowered(clientUserID)) {
const errorText = `${clientUserID} doesn't have the power level required to send state events in the management room. Please make the Draupnir user an administrator.`;
await Task(
clientPlatform
.toRoomMessageSender()
.sendMessage(managementRoomDetail.managementRoomID, {
body: errorText,
msgtype: "m.notice",
})
);
return ResultError.Result(errorText);
}
return Ok(draupnir);
}
public get managementRoomID(): StringRoomID {
return this.managementRoomDetail.managementRoomID;
}
public get managementRoom(): MatrixRoomID {
return this.managementRoomDetail.managementRoom;
}
/**
* Note: This is only public due to having to first start the syncloop before sending events
* when we use encryption.
* This means this is only used in the index.ts.
*/
public async startupComplete(): Promise<void> {
try {
await this.managementRoomOutput.logMessage(
LogLevel.INFO,
"Draupnir@startup",
"Startup complete. Now monitoring rooms."
);
const statusInfo = draupnirStatusInfo(this);
await sendMatrixEventsFromDeadDocument(
this.clientPlatform.toRoomMessageSender(),
this.managementRoomID,
renderStatusInfo(statusInfo),
{}
);
} catch (ex) {
log.error(`Caught an error when trying to show status at startup`, ex);
}
}
public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void {
if (
Value.Check(MembershipEvent, event) &&
event.state_key === this.clientUserID &&
// if the membership is join, make sure that we filter out protected rooms.
(event.content.membership === Membership.Join
? !this.protectedRoomsSet.isProtectedRoom(roomID)
: true)
) {
this.protectedRoomsSet.handleExternalMembership(roomID, event);
}
this.managementRoomMessageListener(roomID, event);
void Task(
(async () => {
await this.reactionHandler.handleEvent(roomID, event);
})()
);
if (this.protectedRoomsSet.isProtectedRoom(roomID)) {
this.protectedRoomsSet.handleTimelineEvent(roomID, event);
}
}
private managementRoomMessageListener(
roomID: StringRoomID,
event: RoomEvent
): void {
if (roomID !== this.managementRoomID) {
return;
}
this.commandDispatcherTimelineListener(roomID, event);
this.reportManager.handleTimelineEvent(roomID, event);
}
/**
* Start responding to events.
* This will not start the appservice from listening and responding
* to events. Nor will it start any syncing client.
*/
public start(): void {
// to avoid handlers getting out of sync on clientRooms and leaking
// when draupnir keeps being started and restarted, we can basically
// clear all listeners each time and add the factory listener back.
this.clientRooms.on("timeline", this.timelineEventListener);
if (this.reportPoller) {
// allow this to crash draupnir if it fails, since we need to know.
void this.reportPoller.startFromStoredSetting(
this.client,
this.managementRoomOutput
);
}
}
public stop(): void {
this.clientRooms.off("timeline", this.timelineEventListener);
this.reportPoller?.stop();
this.protectedRoomsSet.unregisterListeners();
this.stores.dispose();
}
public createRoomReference(roomID: StringRoomID): MatrixRoomID {
return new MatrixRoomID(roomID, [userServerName(this.clientUserID)]);
}
public handleEventReport(report: EventReport): void {
this.protectedRoomsSet.handleEventReport(report);
}
/**
* This is needed to implement the MatrixInterfaceAdaptor interface.
*/
public get commandRoomID() {
return this.managementRoomID;
}
/**
* API for integration tests to be able to test commands, mostly to ensure
* functionality of the appservice bots.
*/
public async sendPresentationCommand<CommandReturn>(
sender: StringUserID,
...items: Presentation[]
): Promise<ActionResult<CommandReturn>> {
return await this.JSInterfaceDispatcher.invokeCommandFromPresentationStream(
{ commandSender: sender },
new StandardPresentationArgumentStream(items)
);
}
public async sendTextCommand<CommandReturn>(
sender: StringUserID,
command: string
): Promise<ActionResult<CommandReturn>> {
return await this.JSInterfaceDispatcher.invokeCommandFromBody(
{ commandSender: sender },
command
);
}
}