From a45d308597c6512b04cbdf875edd140ecd25ba7d Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 14 Feb 2025 13:34:37 +0000 Subject: [PATCH] Refactor and improve the unban command. We no longer want to accept an argument for the list. We will just find all appropriate policies and remove them, like we do with the unban prompt (which we still might want to update to use the new `--no-confirm` prompt later). We fix the bugs where the unban command was inviting users regardless of whether the `--invite` option was provided. The unban command now uses a preview which shows all the policies that will have to be removed to unban a user, all the rooms they will need to be unbanned from, and any rooms that they will be invited to if the `--invite` option is used. --- src/commands/DraupnirCommands.ts | 2 +- src/commands/Unban.ts | 261 -------------- src/commands/unban/Unban.tsx | 401 ++++++++++++++++++++++ src/commands/unban/UnbanEntity.tsx | 79 +++++ src/commands/unban/UnbanUsers.tsx | 200 +++++++++++ test/integration/commands/commandUtils.ts | 37 +- test/integration/commands/unbanTest.ts | 346 +++++++++++++++++++ test/unit/commands/UnbanCommandTest.ts | 12 +- 8 files changed, 1070 insertions(+), 268 deletions(-) delete mode 100644 src/commands/Unban.ts create mode 100644 src/commands/unban/Unban.tsx create mode 100644 src/commands/unban/UnbanEntity.tsx create mode 100644 src/commands/unban/UnbanUsers.tsx create mode 100644 test/integration/commands/unbanTest.ts diff --git a/src/commands/DraupnirCommands.ts b/src/commands/DraupnirCommands.ts index 8a173b6..24ad3ab 100644 --- a/src/commands/DraupnirCommands.ts +++ b/src/commands/DraupnirCommands.ts @@ -45,7 +45,7 @@ import { DraupnirDisplaynameCommand } from "./SetDisplayNameCommand"; import { DraupnirSetPowerLevelCommand } from "./SetPowerLevelCommand"; import { SynapseAdminShutdownRoomCommand } from "./ShutdownRoomCommand"; import { DraupnirStatusCommand } from "./StatusCommand"; -import { DraupnirUnbanCommand } from "./Unban"; +import { DraupnirUnbanCommand } from "./unban/Unban"; import { DraupnirUnwatchPolicyRoomCommand, DraupnirWatchPolicyRoomCommand, diff --git a/src/commands/Unban.ts b/src/commands/Unban.ts deleted file mode 100644 index c101985..0000000 --- a/src/commands/Unban.ts +++ /dev/null @@ -1,261 +0,0 @@ -// Copyright 2022 - 2023 Gnuxie -// Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. -// -// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 -// -// SPDX-FileAttributionText: -// This modified file incorporates work from mjolnir -// https://github.com/matrix-org/mjolnir -// - -import { - isError, - Ok, - PolicyRoomManager, - PolicyRuleType, - RoomInviter, - RoomResolver, - RoomUnbanner, - SetRoomMembership, - WatchedPolicyRooms, -} from "matrix-protection-suite"; -import { LogLevel } from "matrix-bot-sdk"; -import { - isStringUserID, - MatrixGlob, - MatrixUserID, - StringUserID, -} from "@the-draupnir-project/matrix-basic-types"; -import { - describeCommand, - MatrixRoomIDPresentationType, - MatrixRoomReferencePresentationSchema, - MatrixUserIDPresentationType, - StringPresentationType, - tuple, - union, -} from "@the-draupnir-project/interface-manager"; -import { Result, ResultError } from "@gnuxie/typescript-result"; -import { - DraupnirContextToCommandContextTranslator, - DraupnirInterfaceAdaptor, -} from "./DraupnirCommandPrerequisites"; -import ManagementRoomOutput from "../managementroom/ManagementRoomOutput"; -import { UnlistedUserRedactionQueue } from "../queues/UnlistedUserRedactionQueue"; - -async function unbanUserFromRooms( - { - managementRoomOutput, - setMembership, - roomUnbanner, - noop, - roomInviter, - }: DraupnirUnbanCommandContext, - rule: MatrixGlob, - invite: boolean = false -) { - await managementRoomOutput.logMessage( - LogLevel.INFO, - "Unban", - `Unbanning users that match glob: ${rule.regex}` - ); - for (const revision of setMembership.allRooms) { - for (const member of revision.members()) { - if (member.membership !== "ban") { - continue; - } - if (rule.test(member.userID)) { - await managementRoomOutput.logMessage( - LogLevel.DEBUG, - "Unban", - `Unbanning ${member.userID} in ${revision.room.toRoomIDOrAlias()}` + - (invite ? ", and re-inviting them." : ""), - revision.room.toRoomIDOrAlias() - ); - if (!noop) { - await roomUnbanner.unbanUser( - revision.room.toRoomIDOrAlias(), - member.userID - ); - const inviteResult = await roomInviter.inviteUser( - revision.room, - member.userID - ); - if (isError(inviteResult)) { - await managementRoomOutput.logMessage( - LogLevel.WARN, - "Unban", - `Failed to re-invite ${member.userID} to ${revision.room.toRoomIDOrAlias()}`, - revision.room.toRoomIDOrAlias() - ); - } - } else { - await managementRoomOutput.logMessage( - LogLevel.WARN, - "Unban", - `Attempted to unban ${member.userID} in ${revision.room.toRoomIDOrAlias()} but Draupnir is running in no-op mode`, - revision.room.toRoomIDOrAlias() - ); - } - } - } - } -} - -export type DraupnirUnbanCommandContext = { - policyRoomManager: PolicyRoomManager; - watchedPolicyRooms: WatchedPolicyRooms; - roomResolver: RoomResolver; - clientUserID: StringUserID; - setMembership: SetRoomMembership; - managementRoomOutput: ManagementRoomOutput; - noop: boolean; - roomUnbanner: RoomUnbanner; - unlistedUserRedactionQueue: UnlistedUserRedactionQueue; - roomInviter: RoomInviter; -}; - -export const DraupnirUnbanCommand = describeCommand({ - summary: "Removes an entity from a policy list.", - parameters: tuple( - { - name: "entity", - description: - "The entity to ban. This can be a user ID, room ID, or server name.", - acceptor: union( - MatrixUserIDPresentationType, - MatrixRoomReferencePresentationSchema, - StringPresentationType - ), - }, - { - name: "list", - acceptor: union( - MatrixRoomReferencePresentationSchema, - StringPresentationType - ), - prompt: async function ({ - policyRoomManager, - clientUserID, - }: DraupnirUnbanCommandContext) { - return Ok({ - suggestions: policyRoomManager - .getEditablePolicyRoomIDs(clientUserID, PolicyRuleType.User) - .map((room) => MatrixRoomIDPresentationType.wrap(room)), - }); - }, - } - ), - // This is a legacy option to unban the user from all rooms that we now ignore just so providing the option doesn't - // cause an error. - keywords: { - keywordDescriptions: { - true: { - isFlag: true, - description: - "Legacy, now redundant option to unban the user from all rooms.", - }, - invite: { - isFlag: true, - description: - "Re-invite the unbanned user to any rooms they were unbanned from.", - }, - }, - }, - async executor( - context: DraupnirUnbanCommandContext, - _info, - keywords, - _rest, - entity, - policyRoomDesignator - ): Promise> { - const { - roomResolver, - policyRoomManager, - watchedPolicyRooms, - unlistedUserRedactionQueue, - } = context; - const policyRoomReference = - typeof policyRoomDesignator === "string" - ? Ok( - watchedPolicyRooms.findPolicyRoomFromShortcode(policyRoomDesignator) - ?.room - ) - : Ok(policyRoomDesignator); - if (isError(policyRoomReference)) { - return policyRoomReference; - } - if (policyRoomReference.ok === undefined) { - return ResultError.Result( - `Unable to find a policy room from the shortcode ${policyRoomDesignator.toString()}` - ); - } - const policyRoom = await roomResolver.resolveRoom(policyRoomReference.ok); - if (isError(policyRoom)) { - return policyRoom; - } - const policyRoomEditor = await policyRoomManager.getPolicyRoomEditor( - policyRoom.ok - ); - if (isError(policyRoomEditor)) { - return policyRoomEditor; - } - const policyRoomUnban = - entity instanceof MatrixUserID - ? await policyRoomEditor.ok.unbanEntity( - PolicyRuleType.User, - entity.toString() - ) - : typeof entity === "string" - ? await policyRoomEditor.ok.unbanEntity(PolicyRuleType.Server, entity) - : await (async () => { - const bannedRoom = await roomResolver.resolveRoom(entity); - if (isError(bannedRoom)) { - return bannedRoom; - } - return await policyRoomEditor.ok.unbanEntity( - PolicyRuleType.Room, - bannedRoom.ok.toRoomIDOrAlias() - ); - })(); - if (isError(policyRoomUnban)) { - return policyRoomUnban; - } - if (typeof entity === "string" || entity instanceof MatrixUserID) { - const rawEnttiy = typeof entity === "string" ? entity : entity.toString(); - const rule = new MatrixGlob(entity.toString()); - if (isStringUserID(rawEnttiy)) { - unlistedUserRedactionQueue.removeUser(rawEnttiy); - } - await unbanUserFromRooms( - context, - rule, - keywords.getKeywordValue("invite") - ); - } - return Ok(undefined); - }, -}); - -DraupnirContextToCommandContextTranslator.registerTranslation( - DraupnirUnbanCommand, - function (draupnir) { - return { - policyRoomManager: draupnir.policyRoomManager, - watchedPolicyRooms: draupnir.protectedRoomsSet.watchedPolicyRooms, - roomResolver: draupnir.clientPlatform.toRoomResolver(), - clientUserID: draupnir.clientUserID, - setMembership: draupnir.protectedRoomsSet.setRoomMembership, - managementRoomOutput: draupnir.managementRoomOutput, - noop: draupnir.config.noop, - roomUnbanner: draupnir.clientPlatform.toRoomUnbanner(), - unlistedUserRedactionQueue: draupnir.unlistedUserRedactionQueue, - roomInviter: draupnir.clientPlatform.toRoomInviter(), - }; - } -); - -DraupnirInterfaceAdaptor.describeRenderer(DraupnirUnbanCommand, { - isAlwaysSupposedToUseDefaultRenderer: true, -}); diff --git a/src/commands/unban/Unban.tsx b/src/commands/unban/Unban.tsx new file mode 100644 index 0000000..b263e5f --- /dev/null +++ b/src/commands/unban/Unban.tsx @@ -0,0 +1,401 @@ +// Copyright 2022 - 2025 Gnuxie +// Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 +// +// SPDX-FileAttributionText: +// This modified file incorporates work from mjolnir +// https://github.com/matrix-org/mjolnir +// + +import { + Ok, + PolicyRoomManager, + ResultForUsersInSet, + RoomInviter, + RoomResolver, + RoomSetResult, + RoomUnbanner, + SetMembershipPolicyRevision, + SetMembershipRevisionIssuer, + SetRoomMembership, + WatchedPolicyRooms, +} from "matrix-protection-suite"; +import { + MatrixGlob, + MatrixRoomID, + MatrixUserID, + StringUserID, +} from "@the-draupnir-project/matrix-basic-types"; +import { + DeadDocumentJSX, + describeCommand, + DocumentNode, + MatrixRoomReferencePresentationSchema, + MatrixUserIDPresentationType, + StringPresentationType, + tuple, + union, +} from "@the-draupnir-project/interface-manager"; +import { isError, Result } from "@gnuxie/typescript-result"; +import { + DraupnirContextToCommandContextTranslator, + DraupnirInterfaceAdaptor, +} from "../DraupnirCommandPrerequisites"; +import ManagementRoomOutput from "../../managementroom/ManagementRoomOutput"; +import { UnlistedUserRedactionQueue } from "../../queues/UnlistedUserRedactionQueue"; +import { + findMembersMatchingGlob, + findBanPoliciesMatchingUsers, + unbanMembers, +} from "./UnbanUsers"; +import { findPoliciesToRemove, unbanEntity } from "./UnbanEntity"; +import { ListMatches, renderListRules } from "../Rules"; +import { renderRoomSetResult } from "../../capabilities/CommonRenderers"; +import { + renderMentionPill, + renderRoomPill, +} from "../interface-manager/MatrixHelpRenderer"; + +// FIXME: We will need a `policy edit` and `policy remove` command to cover +// for the lack of such functionality in unban now. + +export type UnbanEntityPreview = { + readonly entity: MatrixUserID | MatrixRoomID | string; + readonly policyMatchesToRemove: ListMatches[]; +}; + +export type UnbanEntityResult = { + readonly policyRemovalResult: RoomSetResult; +} & UnbanEntityPreview; + +export type MemberRooms = { + member: StringUserID; + roomsBannedFrom: MatrixRoomID[]; + roomsToInviteTo: MatrixRoomID[]; +}; + +export type UnbanMembersPreview = UnbanEntityPreview & { + readonly entity: MatrixUserID; + readonly membersToUnban: MemberRooms[]; +}; + +// The idea is to to only diplay these if at least one result failed. +export type UnbanMembersResult = UnbanMembersPreview & { + readonly policyRemovalResult: RoomSetResult; + readonly usersUnbanned: ResultForUsersInSet; + readonly usersInvited: ResultForUsersInSet; +}; + +type UnbanCommandResult = + | UnbanEntityPreview + | UnbanMembersPreview + | UnbanEntityResult + | UnbanMembersResult; + +export type DraupnirUnbanCommandContext = { + policyRoomManager: PolicyRoomManager; + watchedPolicyRooms: WatchedPolicyRooms; + roomResolver: RoomResolver; + clientUserID: StringUserID; + setRoomMembership: SetRoomMembership; + setMembership: SetMembershipRevisionIssuer; + setPoliciesMatchingMembership: SetMembershipPolicyRevision; + managementRoomOutput: ManagementRoomOutput; + noop: boolean; + roomUnbanner: RoomUnbanner; + unlistedUserRedactionQueue: UnlistedUserRedactionQueue; + roomInviter: RoomInviter; +}; + +export const DraupnirUnbanCommand = describeCommand({ + summary: "Removes an entity from a policy list.", + parameters: tuple({ + name: "entity", + description: + "The entity to ban. This can be a user ID, room ID, or server name.", + acceptor: union( + MatrixUserIDPresentationType, + MatrixRoomReferencePresentationSchema, + StringPresentationType + ), + }), + keywords: { + keywordDescriptions: { + // This is a legacy option to unban the user from all rooms that we now ignore just so providing the option doesn't + // cause an error. + true: { + isFlag: true, + description: + "Legacy, now redundant option to unban the user from all rooms.", + }, + invite: { + isFlag: true, + description: + "Re-invite the unbanned user to any rooms they were unbanned from.", + }, + "no-confirm": { + isFlag: true, + description: + "Runs the command without the preview of the unban and the confirmation prompt.", + }, + }, + }, + async executor( + { + roomInviter, + roomUnbanner, + setPoliciesMatchingMembership, + policyRoomManager, + watchedPolicyRooms, + unlistedUserRedactionQueue, + setRoomMembership, + }: DraupnirUnbanCommandContext, + _info, + keywords, + _rest, + entity + ): Promise> { + const isNoConfirm = keywords.getKeywordValue("no-confirm", false); + const inviteMembers = + keywords.getKeywordValue("invite", false) ?? false; + if (entity instanceof MatrixUserID) { + const membersToUnban = findMembersMatchingGlob( + setRoomMembership, + new MatrixGlob(entity.toString()), + { inviteMembers } + ); + const policyMatchesToRemove = findBanPoliciesMatchingUsers( + setPoliciesMatchingMembership, + watchedPolicyRooms, + membersToUnban.map((memberRooms) => memberRooms.member) + ); + const unbanInformation = { + policyMatchesToRemove, + membersToUnban, + entity, + }; + if (!isNoConfirm) { + return Ok(unbanInformation); + } else { + return await unbanMembers( + unbanInformation, + { + roomInviter, + roomUnbanner, + policyRoomManager, + unlistedUserRedactionQueue, + }, + { inviteMembers } + ); + } + } else { + const unbanPreview = findPoliciesToRemove( + entity.toString(), + watchedPolicyRooms + ); + if (!isNoConfirm) { + return Ok(unbanPreview); + } else { + return await unbanEntity( + entity.toString(), + policyRoomManager, + unbanPreview + ); + } + } + }, +}); + +DraupnirContextToCommandContextTranslator.registerTranslation( + DraupnirUnbanCommand, + function (draupnir) { + return { + policyRoomManager: draupnir.policyRoomManager, + watchedPolicyRooms: draupnir.protectedRoomsSet.watchedPolicyRooms, + roomResolver: draupnir.clientPlatform.toRoomResolver(), + clientUserID: draupnir.clientUserID, + setRoomMembership: draupnir.protectedRoomsSet.setRoomMembership, + setMembership: draupnir.protectedRoomsSet.setMembership, + setPoliciesMatchingMembership: + draupnir.protectedRoomsSet.setPoliciesMatchingMembership + .currentRevision, + managementRoomOutput: draupnir.managementRoomOutput, + noop: draupnir.config.noop, + roomUnbanner: draupnir.clientPlatform.toRoomUnbanner(), + unlistedUserRedactionQueue: draupnir.unlistedUserRedactionQueue, + roomInviter: draupnir.clientPlatform.toRoomInviter(), + }; + } +); + +function renderPoliciesToRemove(policyMatches: ListMatches[]): DocumentNode { + return ( + + The following policies will be removed: +
    + {policyMatches.map((list) => ( +
  • {renderListRules(list)}
  • + ))} +
+
+ ); +} + +function renderUnbanEntityPreview(preview: UnbanEntityPreview): DocumentNode { + return ( + + You are about to unban the entity {preview.entity.toString()}, do you want + to continue? + {renderPoliciesToRemove(preview.policyMatchesToRemove)} + + ); +} + +function renderMemberRoomsUnbanPreview(memberRooms: MemberRooms): DocumentNode { + return ( +
+ + {renderMentionPill(memberRooms.member, memberRooms.member)} will be + unbanned from {memberRooms.roomsBannedFrom.length} rooms + +
    + {memberRooms.roomsBannedFrom.map((room) => ( +
  • {renderRoomPill(room)}
  • + ))} +
+
+ ); +} + +function renderMemberRoomsInvitePreview( + memberRooms: MemberRooms +): DocumentNode { + if (memberRooms.roomsToInviteTo.length === 0) { + return ; + } + return ( +
+ + {renderMentionPill(memberRooms.member, memberRooms.member)} will be + invited back to {memberRooms.roomsToInviteTo.length} rooms + +
    + {memberRooms.roomsToInviteTo.map((room) => ( +
  • {renderRoomPill(room)}
  • + ))} +
+
+ ); +} + +function renderMemberRoomsPreview(memberRooms: MemberRooms): DocumentNode { + return ( + + {renderMemberRoomsUnbanPreview(memberRooms)} + {renderMemberRoomsInvitePreview(memberRooms)} + + ); +} + +function renderUnbanMembersPreview(preview: UnbanMembersPreview): DocumentNode { + return ( + + {preview.entity.isContainingGlobCharacters() ? ( +

+ You are about to unban users matching the glob{" "} + {preview.entity.toString()} +

+ ) : ( +

+ You are about to unban{" "} + {renderMentionPill( + preview.entity.toString(), + preview.entity.toString() + )} +

+ )} + {renderPoliciesToRemove(preview.policyMatchesToRemove)} + {preview.membersToUnban.length} users will be unbanned: + {preview.membersToUnban.map(renderMemberRoomsPreview)} +
+ ); +} + +function renderPolicyRemovalResult(result: UnbanEntityResult): DocumentNode { + if (result.policyRemovalResult.map.size === 0) { + return ; + } + return ( + + {renderRoomSetResult(result.policyRemovalResult, { + summary: Policies were removed from the following rooms:, + })} + + ); +} + +function renderUnbanEntityResult(result: UnbanEntityResult): DocumentNode { + return ( + +
+ The following policies were found banning this entity + {renderPolicyRemovalResult(result)} +
+
+ ); +} + +function renderUnbanMembersResult(result: UnbanMembersResult): DocumentNode { + return ( + + {renderPolicyRemovalResult(result)} + {[...result.usersUnbanned.map.entries()].map(([userID, roomSetResult]) => + renderRoomSetResult(roomSetResult, { + summary: ( + + {renderMentionPill(userID, userID)} was unbanned from{" "} + {roomSetResult.map.size} rooms: + + ), + }) + )} + {[...result.usersInvited.map.entries()].map(([userID, roomSetResult]) => + renderRoomSetResult(roomSetResult, { + summary: ( + + {renderMentionPill(userID, userID)} was invited back to{" "} + {roomSetResult.map.size} rooms: + + ), + }) + )} + + ); +} + +DraupnirInterfaceAdaptor.describeRenderer(DraupnirUnbanCommand, { + isAlwaysSupposedToUseDefaultRenderer: true, + confirmationPromptJSXRenderer(commandResult) { + if (isError(commandResult)) { + return Ok(undefined); + } else if ("membersToUnban" in commandResult.ok) { + return Ok({renderUnbanMembersPreview(commandResult.ok)}); + } else { + return Ok({renderUnbanEntityPreview(commandResult.ok)}); + } + }, + JSXRenderer(commandResult) { + if (isError(commandResult)) { + return Ok(undefined); + } else if ("usersUnbanned" in commandResult.ok) { + return Ok({renderUnbanMembersResult(commandResult.ok)}); + } else if ("policyRemovalResult" in commandResult.ok) { + return Ok({renderUnbanEntityResult(commandResult.ok)}); + } else { + throw new TypeError( + "The unban command is quite broken you should tell the developers" + ); + } + }, +}); diff --git a/src/commands/unban/UnbanEntity.tsx b/src/commands/unban/UnbanEntity.tsx new file mode 100644 index 0000000..3ee0105 --- /dev/null +++ b/src/commands/unban/UnbanEntity.tsx @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2025 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +// So the purpose of this is just to remove all policies related to an entity. +// Prompt which policies will be removed, and then remove them if it's accepted. +// For finer control, they will need to use policy remove command. + +import { + isStringRoomID, + MatrixRoomID, + StringRoomID, +} from "@the-draupnir-project/matrix-basic-types"; +import { + PolicyRoomManager, + PolicyRuleType, + Recommendation, + RoomSetResultBuilder, + WatchedPolicyRooms, +} from "matrix-protection-suite"; +import { ListMatches } from "../Rules"; +import { isError, Ok, Result } from "@gnuxie/typescript-result"; +import { UnbanEntityPreview, UnbanEntityResult } from "./Unban"; + +export function findPoliciesToRemove( + entity: MatrixRoomID | string, + watchedPolicyRooms: WatchedPolicyRooms +): UnbanEntityPreview { + const entityType = + entity instanceof MatrixRoomID + ? PolicyRuleType.Room + : PolicyRuleType.Server; + const matches: ListMatches[] = []; + for (const profile of watchedPolicyRooms.allRooms) { + matches.push({ + room: profile.room, + roomID: profile.room.toRoomIDOrAlias(), + profile, + matches: profile.revision.allRulesMatchingEntity( + entity.toString(), + entityType, + Recommendation.Ban + ), + }); + } + return { + entity, + policyMatchesToRemove: matches, + }; +} + +export async function unbanEntity( + entity: StringRoomID | string, + policyRoomManager: PolicyRoomManager, + policyMatches: UnbanEntityPreview +): Promise> { + const entityType = isStringRoomID(entity) + ? PolicyRuleType.Room + : PolicyRuleType.Server; + const policiesRemoved = new RoomSetResultBuilder(); + for (const matches of policyMatches.policyMatchesToRemove) { + const editor = await policyRoomManager.getPolicyRoomEditor(matches.room); + if (isError(editor)) { + policiesRemoved.addResult( + matches.roomID, + editor.elaborate("Unable to obtain the policy room editor") + ); + } else { + policiesRemoved.addResult( + matches.roomID, + (await editor.ok.unbanEntity(entityType, entity)) as Result + ); + } + } + return Ok({ + ...policyMatches, + policyRemovalResult: policiesRemoved.getResult(), + }); +} diff --git a/src/commands/unban/UnbanUsers.tsx b/src/commands/unban/UnbanUsers.tsx new file mode 100644 index 0000000..2fce625 --- /dev/null +++ b/src/commands/unban/UnbanUsers.tsx @@ -0,0 +1,200 @@ +// SPDX-FileCopyrightText: 2025 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { Result } from "@gnuxie/typescript-result"; +import { + StringUserID, + MatrixRoomID, + MatrixGlob, + StringRoomID, + userServerName, +} from "@the-draupnir-project/matrix-basic-types"; +import { + SetRoomMembership, + MembershipChange, + Membership, + SetMembershipPolicyRevision, + WatchedPolicyRooms, + PolicyRule, + Recommendation, + RoomUnbanner, + PolicyRoomManager, + RoomInviter, + RoomSetResultBuilder, + ResultForUsersInSetBuilder, + isError, + Ok, + PolicyRuleType, +} from "matrix-protection-suite"; +import { UnlistedUserRedactionQueue } from "../../queues/UnlistedUserRedactionQueue"; +import { ListMatches } from "../Rules"; +import { MemberRooms, UnbanMembersPreview, UnbanMembersResult } from "./Unban"; + +export function findMembersMatchingGlob( + setRoomMembership: SetRoomMembership, + glob: MatrixGlob, + options: { inviteMembers: boolean } +): MemberRooms[] { + const map = new Map(); + const addRoomMembership = ( + membership: MembershipChange, + room: MatrixRoomID + ) => { + const isToInvite = (() => { + if (!options.inviteMembers) { + return false; + } + switch (membership.membership) { + case Membership.Ban: + case Membership.Leave: + return membership.userID !== membership.sender; + default: + return false; + } + })(); + const isToUnban = membership.membership === Membership.Ban; + if (!isToInvite && !isToUnban) { + return; + } + const entry = map.get(membership.userID); + if (entry === undefined) { + map.set(membership.userID, { + member: membership.userID, + roomsBannedFrom: isToUnban ? [room] : [], + roomsToInviteTo: isToInvite ? [room] : [], + }); + } else { + if (isToInvite) { + entry.roomsToInviteTo.push(room); + } + if (isToUnban) { + entry.roomsBannedFrom.push(room); + } + } + }; + for (const revision of setRoomMembership.allRooms) { + for (const membership of revision.members()) { + if (glob.test(membership.userID)) { + addRoomMembership(membership, revision.room); + } + } + } + return [...map.values()]; +} +export function findBanPoliciesMatchingUsers( + setMembershipPolicies: SetMembershipPolicyRevision, + watchedPolicyRooms: WatchedPolicyRooms, + users: StringUserID[] +): ListMatches[] { + const policies = new Map>(); + const addPolicy = (policyRule: PolicyRule) => { + const entry = policies.get(policyRule.sourceEvent.room_id); + if (entry === undefined) { + policies.set(policyRule.sourceEvent.room_id, new Set([policyRule])); + } else { + entry.add(policyRule); + } + }; + for (const user of users) { + const memberPolicies = [ + ...watchedPolicyRooms.currentRevision.allRulesMatchingEntity( + user, + PolicyRuleType.User, + Recommendation.Ban + ), + ...watchedPolicyRooms.currentRevision.allRulesMatchingEntity( + userServerName(user), + PolicyRuleType.Server, + Recommendation.Ban + ), + ]; + for (const policy of memberPolicies) { + addPolicy(policy); + } + } + return [...policies.entries()].map(([roomID, matches]) => { + const profile = watchedPolicyRooms.allRooms.find( + (profile) => profile.room.toRoomIDOrAlias() === roomID + ); + if (profile === undefined) { + throw new TypeError( + `Shouldn't be possible to have sourced policies from an unwatched list` + ); + } + return { + room: profile.room, + roomID: profile.room.toRoomIDOrAlias(), + revision: profile.revision, + profile, + matches: [...matches], + }; + }); +} + +export async function unbanMembers( + members: UnbanMembersPreview, + capabilities: { + roomUnbanner: RoomUnbanner; + policyRoomManager: PolicyRoomManager; + roomInviter: RoomInviter; + unlistedUserRedactionQueue: UnlistedUserRedactionQueue; + }, + options: { inviteMembers: boolean } +): Promise> { + const policiesRemoved = new RoomSetResultBuilder(); + const unbanResultBuilder = new ResultForUsersInSetBuilder(); + const invitationsSent = new ResultForUsersInSetBuilder(); + for (const policyRoom of members.policyMatchesToRemove) { + const policyRoomEditor = + await capabilities.policyRoomManager.getPolicyRoomEditor(policyRoom.room); + if (isError(policyRoomEditor)) { + policiesRemoved.addResult(policyRoom.roomID, policyRoomEditor); + } else { + for (const policy of policyRoom.matches) { + policiesRemoved.addResult( + policyRoom.roomID, + (await policyRoomEditor.ok.removePolicy( + policy.kind, + policy.recommendation, + policy.entity + )) as Result + ); + } + } + } + // There's no point in unbanning and inviting if policies are still enacted against users. + if (!policiesRemoved.getResult().isEveryResultOk) { + return Ok({ + ...members, + policyRemovalResult: policiesRemoved.getResult(), + usersUnbanned: unbanResultBuilder.getResult(), + usersInvited: invitationsSent.getResult(), + }); + } + for (const member of members.membersToUnban) { + capabilities.unlistedUserRedactionQueue.removeUser(member.member); + for (const room of member.roomsBannedFrom) { + unbanResultBuilder.addResult( + member.member, + room.toRoomIDOrAlias(), + await capabilities.roomUnbanner.unbanUser(room, member.member) + ); + } + for (const room of member.roomsToInviteTo) { + if (options.inviteMembers) { + invitationsSent.addResult( + member.member, + room.toRoomIDOrAlias(), + await capabilities.roomInviter.inviteUser(room, member.member) + ); + } + } + } + return Ok({ + ...members, + policyRemovalResult: policiesRemoved.getResult(), + usersUnbanned: unbanResultBuilder.getResult(), + usersInvited: invitationsSent.getResult(), + }); +} diff --git a/test/integration/commands/commandUtils.ts b/test/integration/commands/commandUtils.ts index 51fc513..f9d4e25 100644 --- a/test/integration/commands/commandUtils.ts +++ b/test/integration/commands/commandUtils.ts @@ -11,7 +11,10 @@ import { MatrixClient } from "matrix-bot-sdk"; import { strict as assert } from "assert"; import * as crypto from "crypto"; -import { MatrixEmitter } from "matrix-protection-suite-for-matrix-bot-sdk"; +import { + MatrixEmitter, + MatrixSendClient, +} from "matrix-protection-suite-for-matrix-bot-sdk"; import { NoticeMessageContent, ReactionEvent, @@ -21,6 +24,11 @@ import { StringEventIDSchema, } from "matrix-protection-suite"; import { Type } from "@sinclair/typebox"; +import { Draupnir } from "../../../src/Draupnir"; +import { + StringEventID, + StringRoomID, +} from "@the-draupnir-project/matrix-basic-types"; export const ReplyContent = Type.Intersect([ Type.Object({ @@ -249,3 +257,30 @@ export async function createBanList( ); return listName; } + +export async function sendCommand( + draupnir: Draupnir, + command: string +): Promise<{ eventID: StringEventID }> { + const eventID = (await draupnir.client.sendMessage( + draupnir.managementRoomID, + { + msgtype: "m.text", + body: command, + } + )) as StringEventID; + return { eventID }; +} + +export async function acceptPropmt( + client: MatrixSendClient, + roomID: StringRoomID, + eventID: StringEventID, + promptKey: string +): Promise { + // i suspect that adding a reaction using the unstable API doesn't work because it uses the usntable prefix + // whereas our schema doesn't have the unstable event type. + // we don't test for this anywhere and we should really unify the situation between draupnir, draupnir safe mode, + // and any new bots that just need all the same things. + await client.unstableApis.addReactionToEvent(roomID, eventID, promptKey); +} diff --git a/test/integration/commands/unbanTest.ts b/test/integration/commands/unbanTest.ts new file mode 100644 index 0000000..739ad73 --- /dev/null +++ b/test/integration/commands/unbanTest.ts @@ -0,0 +1,346 @@ +// SPDX-FileCopyrightText: 2025 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +// 1. We need to test glob on user, literal on user, and server on user all get +// removed when using unban. +// 1.a. We need to test that nothing happens when you just enter the command +// without confirmation +// 1.b. We need to test the effects happen after confirmation +// 2. We need to test that invite behaviour is optional. +// 3. We need to test that inviting and unbanning works even when +// There are no policies. +// This probably all needs to be an integration test... So that we can +// Check the rendering. + +import { + Membership, + PolicyRoomEditor, + PolicyRuleType, + Recommendation, +} from "matrix-protection-suite"; +import { Draupnir } from "../../../src/Draupnir"; +import { + MatrixRoomReference, + StringRoomID, + StringUserID, + userLocalpart, + userServerName, +} from "@the-draupnir-project/matrix-basic-types"; +import { DraupnirTestContext } from "../mjolnirSetupUtils"; +import { newTestUser } from "../clientHelper"; +import { + UnbanMembersPreview, + UnbanMembersResult, +} from "../../../src/commands/unban/Unban"; +import expect from "expect"; + +async function createProtectedRoomsSetWithBan( + draupnir: Draupnir, + userToBanUserID: StringUserID, + { numberOfRooms }: { numberOfRooms: number } +): Promise { + return await Promise.all( + [...Array(numberOfRooms)].map(async (_) => { + const roomID = (await draupnir.client.createRoom()) as StringRoomID; + const room = MatrixRoomReference.fromRoomID(roomID, []); + ( + await draupnir.clientPlatform + .toRoomBanner() + .banUser(room, userToBanUserID, "spam") + ).expect("Should be able to ban the user from the room"); + ( + await draupnir.protectedRoomsSet.protectedRoomsManager.addRoom(room) + ).expect("Should be able to protect the newly created room"); + return roomID; + }) + ); +} + +async function createPoliciesBanningUser( + policyRoomEditor: PolicyRoomEditor, + userToBanUserID: StringUserID +): Promise { + ( + await policyRoomEditor.createPolicy( + PolicyRuleType.Server, + Recommendation.Ban, + userServerName(userToBanUserID), + "spam", + {} + ) + ).expect("Should be able to create the server policy"); + ( + await policyRoomEditor.createPolicy( + PolicyRuleType.User, + Recommendation.Ban, + `@${userLocalpart(userToBanUserID)}:*`, + "spam", + {} + ) + ).expect("Should be able to create a glob policy"); + ( + await policyRoomEditor.createPolicy( + PolicyRuleType.User, + Recommendation.Ban, + userToBanUserID, + "spam", + {} + ) + ).expect("Should be able to ban the user directly"); +} + +async function createWatchedPolicyRoom( + draupnir: Draupnir +): Promise { + const policyRoomID = (await draupnir.client.createRoom()) as StringRoomID; + ( + await draupnir.protectedRoomsSet.watchedPolicyRooms.watchPolicyRoomDirectly( + MatrixRoomReference.fromRoomID(policyRoomID) + ) + ).expect("Should be able to watch the new policy room"); + return policyRoomID; +} + +describe("unbanCommandTest", function () { + it( + "Should be able to unban members to protected rooms, removing all policies that will target them", + async function (this: DraupnirTestContext) { + const draupnir = this.draupnir; + if (draupnir === undefined) { + throw new TypeError(`setup didn't run properly`); + } + const falsePositiveUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "accidentally-banned" }, + }); + const falsePositiveUserID = + (await falsePositiveUser.getUserId()) as StringUserID; + const protectedRooms = await createProtectedRoomsSetWithBan( + draupnir, + falsePositiveUserID, + { numberOfRooms: 5 } + ); + const policyRoomID = await createWatchedPolicyRoom(draupnir); + const policyRoomEditor = ( + await draupnir.policyRoomManager.getPolicyRoomEditor( + MatrixRoomReference.fromRoomID(policyRoomID) + ) + ).expect( + "Should be able to get a policy room editor for the newly created policy room" + ); + await createPoliciesBanningUser(policyRoomEditor, falsePositiveUserID); + // wait for policies to be detected. + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Now we can use the unban command to test the preview has no effects + // So the way this can work is we can send the command, get back the event and just know that we can send 'OK' and 'Cancel' to it later and it'll work. + const previewResult = ( + await draupnir.sendTextCommand( + draupnir.clientUserID, + `!draupnir unban ${falsePositiveUserID}` + ) + ).expect( + "We should have been able to get a preview" + ) as UnbanMembersPreview; + expect(previewResult.membersToUnban.length).toBe(1); // hmm we're going to have to put the user on a different server... + expect(previewResult.policyMatchesToRemove.length).toBe(1); + const listMatches = previewResult.policyMatchesToRemove.at(0); + if (listMatches === undefined) { + throw new TypeError("We should have some matches"); + } + expect(listMatches.matches.length).toBe(3); + const falsePositiveMember = previewResult.membersToUnban.at(0); + if (falsePositiveMember === undefined) { + throw new TypeError("We should have some details here"); + } + expect(falsePositiveMember.roomsBannedFrom.length).toBe(5); + expect(falsePositiveMember.roomsToInviteTo.length).toBe(0); + expect(falsePositiveMember.member).toBe(falsePositiveUserID); + // now checked that the user is still banned in all 5 rooms + for (const roomID of protectedRooms) { + const membershipRevision = + draupnir.protectedRoomsSet.setRoomMembership.getRevision(roomID); + if (membershipRevision === undefined) { + throw new TypeError( + "Unable to find membership revision for a protected room, shouldn't happen" + ); + } + expect( + membershipRevision.membershipForUser(falsePositiveUserID)?.membership + ).toBe(Membership.Ban); + } + ( + await draupnir.sendTextCommand( + draupnir.clientUserID, + `!draupnir unban ${falsePositiveUserID} --no-confirm` + ) + ).expect( + "We should have been able to run the command" + ) as UnbanMembersResult; + // wait for events to come down sync + await new Promise((resolve) => setTimeout(resolve, 1000)); + // now check that they are unbanned + for (const roomID of protectedRooms) { + const membershipRevision = + draupnir.protectedRoomsSet.setRoomMembership.getRevision(roomID); + if (membershipRevision === undefined) { + throw new TypeError( + "Unable to find membership revision for a protected room, shouldn't happen" + ); + } + expect( + membershipRevision.membershipForUser(falsePositiveUserID)?.membership + ).toBe(Membership.Leave); + } + // verify the policies are removed. + const policyRevision = + draupnir.protectedRoomsSet.watchedPolicyRooms.currentRevision; + expect(policyRevision.allRules.length).toBe(0); + + // (Bonus) now check that if we run the command again, then the user will be reinvited even though they have been unbanned. + const inviteResult = ( + await draupnir.sendTextCommand( + draupnir.clientUserID, + `!draupnir unban ${falsePositiveUserID} --no-confirm --invite` + ) + ).expect( + "We should have been able to run the command" + ) as UnbanMembersResult; + expect(inviteResult.usersInvited.map.size).toBe(1); + expect(inviteResult.membersToUnban.at(0)?.roomsToInviteTo.length).toBe(5); + await new Promise((resolve) => setTimeout(resolve, 1000)); + for (const roomID of protectedRooms) { + const membershipRevision = + draupnir.protectedRoomsSet.setRoomMembership.getRevision(roomID); + if (membershipRevision === undefined) { + throw new TypeError( + "Unable to find membership revision for a protected room, shouldn't happen" + ); + } + expect( + membershipRevision.membershipForUser(falsePositiveUserID)?.membership + ).toBe(Membership.Invite); + } + } as unknown as Mocha.AsyncFunc + ); + it("Unbans users even when there are no policies", async function ( + this: DraupnirTestContext + ) { + const draupnir = this.draupnir; + if (draupnir === undefined) { + throw new TypeError(`setup didn't run properly`); + } + const falsePositiveUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "accidentally-banned" }, + }); + const falsePositiveUserID = + (await falsePositiveUser.getUserId()) as StringUserID; + const protectedRooms = await createProtectedRoomsSetWithBan( + draupnir, + falsePositiveUserID, + { numberOfRooms: 5 } + ); + // verify that there are no policies. + const policyRevision = + draupnir.protectedRoomsSet.watchedPolicyRooms.currentRevision; + expect(policyRevision.allRules.length).toBe(0); + ( + await draupnir.sendTextCommand( + draupnir.clientUserID, + `!draupnir unban ${falsePositiveUserID} --no-confirm` + ) + ).expect( + "We should have been able to run the command" + ) as UnbanMembersResult; + // wait for events to come down sync + await new Promise((resolve) => setTimeout(resolve, 1000)); + // now check that they are unbanned + for (const roomID of protectedRooms) { + const membershipRevision = + draupnir.protectedRoomsSet.setRoomMembership.getRevision(roomID); + if (membershipRevision === undefined) { + throw new TypeError( + "Unable to find membership revision for a protected room, shouldn't happen" + ); + } + expect( + membershipRevision.membershipForUser(falsePositiveUserID)?.membership + ).toBe(Membership.Leave); + } + } as unknown as Mocha.AsyncFunc); + it( + "Unbans and reinvites users when the invite option is provided", + async function (this: DraupnirTestContext) { + const draupnir = this.draupnir; + if (draupnir === undefined) { + throw new TypeError(`setup didn't run properly`); + } + const falsePositiveUser = await newTestUser(this.config.homeserverUrl, { + name: { contains: "accidentally-banned" }, + }); + const falsePositiveUserID = + (await falsePositiveUser.getUserId()) as StringUserID; + const protectedRooms = await createProtectedRoomsSetWithBan( + draupnir, + falsePositiveUserID, + { numberOfRooms: 5 } + ); + + // Now we can use the unban command to test the preview has no effects + // So the way this can work is we can send the command, get back the event and just know that we can send 'OK' and 'Cancel' to it later and it'll work. + const previewResult = ( + await draupnir.sendTextCommand( + draupnir.clientUserID, + `!draupnir unban ${falsePositiveUserID} --invite` + ) + ).expect( + "We should have been able to get a preview" + ) as UnbanMembersPreview; + expect(previewResult.membersToUnban.length).toBe(1); // hmm we're going to have to put the user on a different server... + expect(previewResult.policyMatchesToRemove.length).toBe(0); + const falsePositiveMember = previewResult.membersToUnban.at(0); + if (falsePositiveMember === undefined) { + throw new TypeError("We should have some details here"); + } + expect(falsePositiveMember.roomsBannedFrom.length).toBe(5); + expect(falsePositiveMember.roomsToInviteTo.length).toBe(5); + expect(falsePositiveMember.member).toBe(falsePositiveUserID); + // now checked that the user is still banned in all 5 rooms + for (const roomID of protectedRooms) { + const membershipRevision = + draupnir.protectedRoomsSet.setRoomMembership.getRevision(roomID); + if (membershipRevision === undefined) { + throw new TypeError( + "Unable to find membership revision for a protected room, shouldn't happen" + ); + } + expect( + membershipRevision.membershipForUser(falsePositiveUserID)?.membership + ).toBe(Membership.Ban); + } + ( + await draupnir.sendTextCommand( + draupnir.clientUserID, + `!draupnir unban ${falsePositiveUserID} --invite --no-confirm` + ) + ).expect( + "We should have been able to run the command" + ) as UnbanMembersResult; + // wait for events to come down sync + await new Promise((resolve) => setTimeout(resolve, 1000)); + // now check that they are unbanned + for (const roomID of protectedRooms) { + const membershipRevision = + draupnir.protectedRoomsSet.setRoomMembership.getRevision(roomID); + if (membershipRevision === undefined) { + throw new TypeError( + "Unable to find membership revision for a protected room, shouldn't happen" + ); + } + expect( + membershipRevision.membershipForUser(falsePositiveUserID)?.membership + ).toBe(Membership.Invite); + } + } as unknown as Mocha.AsyncFunc + ); +}); diff --git a/test/unit/commands/UnbanCommandTest.ts b/test/unit/commands/UnbanCommandTest.ts index 121c4f6..e48831f 100644 --- a/test/unit/commands/UnbanCommandTest.ts +++ b/test/unit/commands/UnbanCommandTest.ts @@ -25,7 +25,7 @@ import { } from "matrix-protection-suite"; import { createMock } from "ts-auto-mock"; import expect from "expect"; -import { DraupnirUnbanCommand } from "../../../src/commands/Unban"; +import { DraupnirUnbanCommand } from "../../../src/commands/unban/Unban"; import ManagementRoomOutput from "../../../src/managementroom/ManagementRoomOutput"; import { UnlistedUserRedactionQueue } from "../../../src/queues/UnlistedUserRedactionQueue"; @@ -129,7 +129,7 @@ describe("Test the DraupnirUnbanCommand", function () { DraupnirUnbanCommand, { policyRoomManager: mockPolicyRoomManager, - setMembership: protectedRoomsSet.setRoomMembership, + setRoomMembership: protectedRoomsSet.setRoomMembership, managementRoomOutput: createMock(), roomResolver, watchedPolicyRooms: protectedRoomsSet.watchedPolicyRooms, @@ -142,12 +142,14 @@ describe("Test the DraupnirUnbanCommand", function () { return Ok(undefined); }, }), + setMembership: protectedRoomsSet.setMembership, + setPoliciesMatchingMembership: + protectedRoomsSet.setPoliciesMatchingMembership.currentRevision, }, { rest: ["spam"], }, - MatrixUserID.fromUserID(ExistingBanUserID), - policyRoom + MatrixUserID.fromUserID(ExistingBanUserID) ); expect(banResult.isOkay).toBe(true); const membership = protectedRoomsSet.setRoomMembership.getRevision( @@ -159,7 +161,7 @@ describe("Test the DraupnirUnbanCommand", function () { ); } expect(membership.membershipForUser(ExistingBanUserID)?.membership).toBe( - Membership.Leave + Membership.Ban ); }); });