Skip to content

Commit

Permalink
Merge pull request #422 from the-draupnir-project/gnuxie/invitations-fun
Browse files Browse the repository at this point in the history
Invitation protections
the-draupnir-project/planning#15
  • Loading branch information
Gnuxie authored May 22, 2024
2 parents 771edf3 + 16435bd commit 829712c
Show file tree
Hide file tree
Showing 14 changed files with 603 additions and 158 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@
"js-yaml": "^4.1.0",
"jsdom": "^24.0.0",
"matrix-appservice-bridge": "^9.0.1",
"matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@0.20.0",
"matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.20.0",
"matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@0.22.0",
"matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@0.22.0",
"parse-duration": "^1.0.2",
"pg": "^8.8.0",
"shell-quote": "^1.7.3",
Expand Down
112 changes: 40 additions & 72 deletions src/Draupnir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,25 @@ limitations under the License.
* are NOT distributed, contributed, committed, or licensed under the Apache License.
*/

import { ActionResult, Client, ClientPlatform, ClientRooms, EventReport, LoggableConfigTracker, Logger, MatrixRoomID, MatrixRoomReference, Membership, MembershipEvent, Ok, PolicyRoomManager, ProtectedRoomsSet, RoomEvent, RoomMembershipManager, RoomMessage, RoomStateManager, StringRoomID, StringUserID, Task, TextMessageContent, Value, isError, isStringRoomAlias, isStringRoomID, serverName, userLocalpart } from "matrix-protection-suite";
import { ActionResult, Client, ClientPlatform, ClientRooms, EventReport, LoggableConfigTracker, Logger, MatrixRoomID, MatrixRoomReference, MembershipEvent, Ok, PolicyRoomManager, ProtectedRoomsSet, RoomEvent, RoomMembershipManager, RoomMembershipRevisionIssuer, RoomMessage, RoomStateManager, StringRoomID, StringUserID, Task, TextMessageContent, Value, isError, isStringRoomAlias, isStringRoomID, serverName, userLocalpart } from "matrix-protection-suite";
import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue";
import { findCommandTable } from "./commands/interface-manager/InterfaceCommand";
import { ThrottlingQueue } from "./queues/ThrottlingQueue";
import ManagementRoomOutput from "./ManagementRoomOutput";
import { ReportPoller } from "./report/ReportPoller";
import { ReportManager } from "./report/ReportManager";
import { MatrixReactionHandler } from "./commands/interface-manager/MatrixReactionHandler";
import { MatrixSendClient, SynapseAdminClient, resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk";
import { MatrixSendClient, SynapseAdminClient } from "matrix-protection-suite-for-matrix-bot-sdk";
import { IConfig } from "./config";
import { COMMAND_PREFIX, DraupnirContext, extractCommandFromMessageBody, handleCommand } from "./commands/CommandHandler";
import { htmlEscape } from "./utils";
import { LogLevel } from "matrix-bot-sdk";
import { ARGUMENT_PROMPT_LISTENER, DEFAUILT_ARGUMENT_PROMPT_LISTENER, makeListenerForArgumentPrompt as makeListenerForArgumentPrompt, makeListenerForPromptDefault } from "./commands/interface-manager/MatrixPromptForAccept";
import { RendererMessageCollector } from "./capabilities/RendererMessageCollector";
import { DraupnirRendererMessageCollector } from "./capabilities/DraupnirRendererMessageCollector";
import { renderProtectionFailedToStart } from "./protections/ProtectedRoomsSetRenderers";
import { draupnirStatusInfo, renderStatusInfo } from "./commands/StatusCommand";
import { renderMatrixAndSend } from "./commands/interface-manager/DeadDocumentMatrix";
import { isInvitationForUser } from "./protections/invitation/inviteCore";

const log = new Logger('Draupnir');

Expand Down Expand Up @@ -97,13 +97,16 @@ export class Draupnir implements Client {
public readonly policyRoomManager: PolicyRoomManager,
public readonly roomMembershipManager: RoomMembershipManager,
public readonly loggableConfigTracker: LoggableConfigTracker,
/** Mjolnir 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 synapseAdminClient?: SynapseAdminClient,
) {
this.managementRoomID = this.managementRoom.toRoomIDOrAlias();
this.managementRoomOutput = new ManagementRoomOutput(
this.managementRoomID, this.clientUserID, this.client, this.config
);
this.reactionHandler = new MatrixReactionHandler(this.managementRoom.toRoomIDOrAlias(), client, clientUserID);
this.reactionHandler = new MatrixReactionHandler(this.managementRoom.toRoomIDOrAlias(), client, clientUserID, clientPlatform);
this.reportManager = new ReportManager(this);
if (config.pollReports) {
this.reportPoller = new ReportPoller(this, this.reportManager);
Expand Down Expand Up @@ -144,6 +147,34 @@ export class Draupnir implements Client {
config: IConfig,
loggableConfigTracker: LoggableConfigTracker
): Promise<ActionResult<Draupnir>> {
const acceptInvitesFromRoom = await (async () => {
if (config.autojoinOnlyIfManager) {
return Ok(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;
}
const acceptInvitesFromRoomIssuer = await roomMembershipManager.getRoomMembershipRevisionIssuer(acceptInvitesFromRoom.ok);
if (isError(acceptInvitesFromRoomIssuer)) {
return acceptInvitesFromRoomIssuer;
}
const draupnir = new Draupnir(
client,
clientUserID,
Expand All @@ -156,6 +187,8 @@ export class Draupnir implements Client {
policyRoomManager,
roomMembershipManager,
loggableConfigTracker,
acceptInvitesFromRoom.ok,
acceptInvitesFromRoomIssuer.ok,
new SynapseAdminClient(
client,
clientUserID
Expand Down Expand Up @@ -199,7 +232,9 @@ export class Draupnir implements Client {
}

public handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void {
Task(this.joinOnInviteListener(roomID, event));
if (Value.Check(MembershipEvent, event) && isInvitationForUser(event, this.clientUserID)) {
this.protectedRoomsSet.handleExternalInvite(roomID, event);
}
this.managementRoomMessageListener(roomID, event);
this.reactionHandler.handleEvent(roomID, event);
if (this.protectedRoomsSet.isProtectedRoom(roomID)) {
Expand Down Expand Up @@ -239,73 +274,6 @@ export class Draupnir implements Client {
this.reportManager.handleTimelineEvent(roomID, event);
}

/**
* Adds a listener to the client that will automatically accept invitations.
* FIXME: This is just copied in from Mjolnir and there are plenty of places for uncaught exceptions that will cause havok.
* FIXME: MOVE TO A PROTECTION.
* @param {MatrixSendClient} client
* @param options By default accepts invites from anyone.
* @param {string} options.managementRoom The room to report ignored invitations to if `recordIgnoredInvites` is true.
* @param {boolean} options.recordIgnoredInvites Whether to report invites that will be ignored to the `managementRoom`.
* @param {boolean} options.autojoinOnlyIfManager Whether to only accept an invitation by a user present in the `managementRoom`.
* @param {string} options.acceptInvitesFromSpace A space of users to accept invites from, ignores invites form users not in this space.
*/
private async joinOnInviteListener(roomID: StringRoomID, event: RoomEvent): Promise<void> {
if (Value.Check(MembershipEvent, event) && event.state_key === this.clientUserID) {
const inviteEvent = event;
const reportInvite = async () => {
if (!this.config.recordIgnoredInvites) return; // Nothing to do

Task((async () => {
await this.client.sendMessage(this.managementRoomID, {
msgtype: "m.text",
body: `${inviteEvent.sender} has invited me to ${inviteEvent.room_id} but the config prevents me from accepting the invitation. `
+ `If you would like this room protected, use "!mjolnir rooms add ${inviteEvent.room_id}" so I can accept the invite.`,
format: "org.matrix.custom.html",
formatted_body: `${htmlEscape(inviteEvent.sender)} has invited me to ${htmlEscape(inviteEvent.room_id)} but the config prevents me from `
+ `accepting the invitation. If you would like this room protected, use <code>!mjolnir rooms add ${htmlEscape(inviteEvent.room_id)}</code> `
+ `so I can accept the invite.`,
});
return Ok(undefined);
})());
};

if (this.config.autojoinOnlyIfManager) {
const managementMembership = this.protectedRoomsSet.setMembership.getRevision(this.managementRoomID);
if (managementMembership === undefined) {
throw new TypeError(`Processing an invitation before the protected rooms set has properly initialized. Are we protecting the management room?`);
}
const senderMembership = managementMembership.membershipForUser(inviteEvent.sender);
if (senderMembership?.membership !== Membership.Join) return reportInvite(); // ignore invite
} else {
if (!(isStringRoomID(this.config.acceptInvitesFromSpace) || isStringRoomAlias(this.config.acceptInvitesFromSpace))) {
// FIXME: We need to do StringRoomID stuff at parse time of the config.
throw new TypeError(`${this.config.acceptInvitesFromSpace} is not a valid room ID or Alias`);
}
const spaceReference = MatrixRoomReference.fromRoomIDOrAlias(this.config.acceptInvitesFromSpace);
const spaceID = await resolveRoomReferenceSafe(this.client, spaceReference);
if (isError(spaceID)) {
await this.managementRoomOutput.logMessage(LogLevel.ERROR, 'Draupnir', `Unable to resolve the space ${spaceReference.toPermalink} from config.acceptInvitesFromSpace when trying to accept an invitation from ${inviteEvent.sender}`);
}
const spaceId = await this.client.resolveRoom(this.config.acceptInvitesFromSpace);
const spaceUserIds = await this.client.getJoinedRoomMembers(spaceId)
.catch(async e => {
if (e.body?.errcode === "M_FORBIDDEN") {
await this.managementRoomOutput.logMessage(LogLevel.ERROR, 'Mjolnir', `Mjolnir is not in the space configured for acceptInvitesFromSpace, did you invite it?`);
await this.client.joinRoom(spaceId);
return await this.client.getJoinedRoomMembers(spaceId);
} else {
return Promise.reject(e);
}
});
if (!spaceUserIds.includes(inviteEvent.sender)) {
return reportInvite(); // ignore invite
}
}
await this.client.joinRoom(roomID);
}
}

/**
* Start responding to events.
* This will not start the appservice from listening and responding
Expand Down
2 changes: 1 addition & 1 deletion src/DraupnirBotMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export async function makeDraupnirBotModeFromConfig(
DefaultEventDecoder,
backingStore
);
const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap);
const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap, DefaultEventDecoder);
const draupnirFactory = new DraupnirFactory(
clientsInRoomMap,
clientCapabilityFactory,
Expand Down
2 changes: 1 addition & 1 deletion src/appservice/AppService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export class MjolnirAppService {
clientProvider,
eventDecoder
);
const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap);
const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap, eventDecoder);
const botUserID = bridge.getBot().getUserId() as StringUserID;
const clientRooms = await clientsInRoomMap.makeClientRooms(
botUserID,
Expand Down
3 changes: 2 additions & 1 deletion src/appservice/bot/AppserviceCommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ export class AppserviceCommandHandler {
this.reactionHandler = new MatrixReactionHandler(
this.appservice.accessControlRoomID,
this.appservice.bridge.getBot().getClient(),
this.appservice.botUserID
this.appservice.botUserID,
clientPlatform
);
this.commandContext = {
appservice: this.appservice,
Expand Down
64 changes: 54 additions & 10 deletions src/commands/interface-manager/MatrixHelpRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { DocumentNode } from "./DeadDocument";
import { renderMatrixAndSend } from "./DeadDocumentMatrix";
import { LogService } from "matrix-bot-sdk";
import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk";
import { ActionException, ActionResult, MatrixRoomReference, RoomEvent, StringRoomID, isError } from "matrix-protection-suite";
import { ActionError, ActionException, ActionExceptionKind, ActionResult, MatrixRoomReference, Ok, RoomEvent, StringRoomID, Task, isError, isOk } from "matrix-protection-suite";
import { renderDetailsNotice, renderElaborationTrail, renderExceptionTrail } from "../../capabilities/CommonRenderers";

function requiredArgument(argumentName: string): string {
return `<${argumentName}>`;
Expand Down Expand Up @@ -88,17 +89,62 @@ export async function renderHelp(client: MatrixSendClient, commandRoomID: String
);
}

export const tickCrossRenderer: RendererSignature<MatrixContext, BaseFunction> = async function tickCrossRenderer(this: MatrixInterfaceAdaptor<MatrixContext, BaseFunction>, client: MatrixSendClient, commandRoomID: StringRoomID, event: RoomEvent, result: ActionResult<unknown>): Promise<void> {
const react = async (emote: string) => {
export async function reactToEventWithResult(client: MatrixSendClient, event: RoomEvent, result: ActionResult<unknown>): Promise<ActionResult<void>> {
// implement this so we can use it in the invitation protection
// then in the invitation protection makes ure we render when the listener fails
// then in the ban propagation protection also do this.
const react = async (emote: string): Promise<ActionResult<void>> => {
try {
await client.unstableApis.addReactionToEvent(commandRoomID, event['event_id'], emote);
await client.unstableApis.addReactionToEvent(event.room_id, event.event_id, emote);
return Ok(undefined);
} catch (e) {
LogService.error("tickCrossRenderer", "Couldn't react to the event", event['event_id'], e);
return ActionException.Result(`tickCrossRenderer Couldn't react to the event ${event.event_id}`, {
exception: e,
exceptionKind: ActionExceptionKind.Unknown
});
}
}
if (result.isOkay) {
await react('✅')
};
if (isOk(result)) {
return await react('✅');
} else {
return await react('❌');
}
}

export async function replyToEventWithErrorDetails(client: MatrixSendClient, event: RoomEvent, error: ActionError): Promise<ActionResult<void>> {
try {
await renderMatrixAndSend(
<root>
<details>
<summary>{error.mostRelevantElaboration}</summary>
{renderDetailsNotice(error)}
{renderElaborationTrail(error)}
{renderExceptionTrail(error)}
</details>
</root>,
event.room_id,
event,
client,
);
return Ok(undefined);
} catch (e) {
return ActionException.Result(`replyToEventIfError Couldn't send a reply to the event ${event.event_id}`, {
exception: e,
exceptionKind: ActionExceptionKind.Unknown
});
}
}

export function renderActionResultToEvent(client: MatrixSendClient, event: RoomEvent, result: ActionResult<void>): void {
if (isError(result)) {
void Task(replyToEventWithErrorDetails(client, event, result.error));
}
void Task(reactToEventWithResult(client, event, result));
}

export const tickCrossRenderer: RendererSignature<MatrixContext, BaseFunction> = async function tickCrossRenderer(this: MatrixInterfaceAdaptor<MatrixContext, BaseFunction>, client: MatrixSendClient, commandRoomID: StringRoomID, event: RoomEvent, result: ActionResult<unknown>): Promise<void> {
void Task(reactToEventWithResult(client, event, result));
if (isError(result)) {
if (result.error instanceof ArgumentParseError) {
await renderMatrixAndSend(
renderArgumentParseError(this.interfaceCommand, result.error),
Expand All @@ -116,8 +162,6 @@ export const tickCrossRenderer: RendererSignature<MatrixContext, BaseFunction> =
} else {
await client.replyNotice(commandRoomID, event, result.error.message);
}
// reacting is way less important than communicating what happened, do it last.
await react('❌');
}
}

Expand Down
Loading

0 comments on commit 829712c

Please sign in to comment.