diff --git a/package.json b/package.json index d6ad996b..b4264eaa 100644 --- a/package.json +++ b/package.json @@ -70,8 +70,8 @@ "jsdom": "^24.0.0", "matrix-appservice-bridge": "^10.3.1", "matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk", - "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@1.7.0", - "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@1.7.0", + "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@2.0.0", + "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.0.0", "parse-duration": "^1.0.2", "pg": "^8.8.0", "shell-quote": "^1.7.3", diff --git a/src/commands/DraupnirCommands.ts b/src/commands/DraupnirCommands.ts index b26e23d4..eba85da8 100644 --- a/src/commands/DraupnirCommands.ts +++ b/src/commands/DraupnirCommands.ts @@ -49,6 +49,7 @@ import { } from "./WatchUnwatchCommand"; import { DraupnirTopLevelCommands } from "./DraupnirCommandTable"; import { DraupnirSafeModeCommand } from "./SafeModeCommand"; +import { DraupnirProtectionsShowCommand } from "./ProtectionsShowCommand"; // TODO: These commands should all be moved to subdirectories tbh and this // should be split like an index file for each subdirectory. @@ -83,6 +84,7 @@ const DraupnirCommands = new StandardCommandTable("draupnir") "config", "set", ]) + .internCommand(DraupnirProtectionsShowCommand, ["protections", "show"]) .internCommand(DraupnirRedactCommand, ["redact"]) .internCommand(DraupnirResolveAliasCommand, ["resolve"]) .internCommand(DraupnirListProtectedRoomsCommand, ["rooms"]) diff --git a/src/commands/ProtectionsCommands.tsx b/src/commands/ProtectionsCommands.tsx index d67dd3af..1408d8da 100644 --- a/src/commands/ProtectionsCommands.tsx +++ b/src/commands/ProtectionsCommands.tsx @@ -11,12 +11,14 @@ import { ActionError, ActionResult, + ConfigDescription, + EDStatic, Ok, + ProtectedRoomsSet, Protection, ProtectionDescription, - ProtectionSetting, - ProtectionSettings, - UnknownSettings, + ProtectionsManager, + UnknownConfig, findProtection, getAllProtections, isError, @@ -31,7 +33,10 @@ import { tuple, } from "@the-draupnir-project/interface-manager"; import { Result } from "@gnuxie/typescript-result"; -import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites"; +import { + DraupnirContextToCommandContextTranslator, + DraupnirInterfaceAdaptor, +} from "./DraupnirCommandPrerequisites"; export const DraupnirProtectionsEnableCommand = describeCommand({ summary: "Enable a named protection.", @@ -39,15 +44,6 @@ export const DraupnirProtectionsEnableCommand = describeCommand({ name: "protection name", acceptor: StringPresentationType, }), - keywords: { - keywordDescriptions: { - "consequence-provider": { - acceptor: StringPresentationType, - description: - "The name of a consequence provider to use for this protection.", - }, - }, - }, async executor( draupnir: Draupnir, _info, @@ -61,18 +57,8 @@ export const DraupnirProtectionsEnableCommand = describeCommand({ `Couldn't find a protection named ${protectionName}` ); } - const capabilityProviderSet = - await draupnir.protectedRoomsSet.protections.getCapabilityProviderSet( - protectionDescription - ); - if (isError(capabilityProviderSet)) { - return capabilityProviderSet.elaborate( - `Couldn't load the capability provider set for the protection ${protectionName}` - ); - } return await draupnir.protectedRoomsSet.protections.addProtection( protectionDescription, - capabilityProviderSet.ok, draupnir.protectedRoomsSet, draupnir ); @@ -136,14 +122,21 @@ const CommonProtectionSettingParameters = tuple( ); interface SettingChangeSummary< - Key extends string = string, - TSettings extends UnknownSettings = UnknownSettings, + TConfig extends UnknownConfig = UnknownConfig, + Key extends keyof EDStatic = keyof EDStatic, > { - readonly oldValue: unknown; - readonly newValue: unknown; - readonly description: ProtectionSetting; + readonly oldValue: EDStatic[Key]; + readonly newValue: EDStatic[Key]; + readonly propertyKey: Key; + readonly description: ConfigDescription; } +export type ProtectionsConfigCommandContext = { + readonly protectionContext: ProtectionContext; + readonly protectionsManager: ProtectionsManager; + readonly protectedRoomsSet: ProtectedRoomsSet; +}; + export const DraupnirProtectionsConfigSetCommand = describeCommand({ summary: "Set a new value for the protection setting, if the setting is a collection then this will write over the entire collection.", @@ -153,7 +146,11 @@ export const DraupnirProtectionsConfigSetCommand = describeCommand({ description: "The new value to give the protection setting", }), async executor( - draupnir: Draupnir, + { + protectionsManager, + protectionContext, + protectedRoomsSet, + }: ProtectionsConfigCommandContext, _info, _keywords, _rest, @@ -162,7 +159,7 @@ export const DraupnirProtectionsConfigSetCommand = describeCommand({ value ): Promise> { const detailsResult = await findSettingDetailsForCommand( - draupnir, + protectionsManager, protectionName, settingName ); @@ -170,16 +167,17 @@ export const DraupnirProtectionsConfigSetCommand = describeCommand({ return detailsResult; } const details = detailsResult.ok; - const newSettings = details.protectionSettings.setValue( - details.previousSettings, - settingName, - value - ); + const newSettings = details.description + .toMirror() + // we have to reserialize or present the argument or we'll be SOL. + .setSerializedValue(details.previousSettings, settingName, String(value)); if (isError(newSettings)) { return newSettings; } return await changeSettingsForCommands( - draupnir, + protectionContext, + protectedRoomsSet, + protectionsManager, details, settingName, newSettings.ok @@ -195,7 +193,11 @@ export const DraupnirProtectionsConfigAddCommand = describeCommand({ description: "An item to add to the collection setting.", }), async executor( - draupnir: Draupnir, + { + protectionsManager, + protectionContext, + protectedRoomsSet, + }: ProtectionsConfigCommandContext, _info, _keywords, _rest, @@ -204,7 +206,7 @@ export const DraupnirProtectionsConfigAddCommand = describeCommand({ value ): Promise> { const detailsResult = await findSettingDetailsForCommand( - draupnir, + protectionsManager, protectionName, settingName ); @@ -212,21 +214,27 @@ export const DraupnirProtectionsConfigAddCommand = describeCommand({ return detailsResult; } const details = detailsResult.ok; - const settingDescription = details.settingDescription; - if (!settingDescription.isCollectionSetting()) { + const propertyDescription = + details.description.getPropertyDescription(settingName); + if (!propertyDescription.isArray) { return ActionError.Result( `${protectionName}'s setting ${settingName} is not a collection protection setting, and cannot be used with the add or remove commands.` ); } - const newSettings = settingDescription.addItem( - details.previousSettings, - value - ); + const newSettings = details.description + .toMirror() + // We technically need to print the argument "readbly" but i don't think + // we have a way to do that. + // at least without getting the argument from the argument stream in + // interface-manager so that we still have its presentation type. + .addSerializedItem(details.previousSettings, settingName, String(value)); if (isError(newSettings)) { return newSettings; } return await changeSettingsForCommands( - draupnir, + protectionContext, + protectedRoomsSet, + protectionsManager, details, settingName, newSettings.ok @@ -242,7 +250,11 @@ export const DraupnirProtectionsConfigRemoveCommand = describeCommand({ description: "An item to add to the collection setting.", }), async executor( - draupnir: Draupnir, + { + protectionsManager, + protectionContext, + protectedRoomsSet, + }: ProtectionsConfigCommandContext, _info, _keywords, _rest, @@ -251,7 +263,7 @@ export const DraupnirProtectionsConfigRemoveCommand = describeCommand({ value ): Promise> { const detailsResult = await findSettingDetailsForCommand( - draupnir, + protectionsManager, protectionName, settingName ); @@ -259,24 +271,29 @@ export const DraupnirProtectionsConfigRemoveCommand = describeCommand({ return detailsResult; } const details = detailsResult.ok; - const settingDescription = details.settingDescription; - if (!settingDescription.isCollectionSetting()) { + const settingDescription = details.description; + const propertyDescription = + settingDescription.getPropertyDescription(settingName); + if (!propertyDescription.isArray) { return ActionError.Result( `${protectionName}'s setting ${settingName} is not a collection protection setting, and cannot be used with the add or remove commands.` ); } - const newSettings = settingDescription.removeItem( - details.previousSettings, - value - ); - if (isError(newSettings)) { - return newSettings; - } + const newSettings = settingDescription + .toMirror() + .filterItems( + details.previousSettings, + settingName, + (item) => item !== value + ); return await changeSettingsForCommands( - draupnir, + protectionContext, + protectedRoomsSet, + protectionsManager, details, - settingName, - newSettings.ok + // Yeha I know this sucks but either fix it or fuck off, it'll be fine. + settingName as never, + newSettings.ok as never ); }, }); @@ -284,17 +301,17 @@ export const DraupnirProtectionsConfigRemoveCommand = describeCommand({ function renderSettingChangeSummary( summary: SettingChangeSummary ): DocumentNode { - const oldJSON = summary.description.toJSON({ - [summary.description.key]: summary.oldValue, - }); - const newJSON = summary.description.toJSON({ - [summary.description.key]: summary.newValue, - }); + const renderProperty = (value: unknown) => { + if (Array.isArray(value)) { + return value.join(", "); + } + return String(value); + }; return ( - Setting {summary.description.key} changed from{" "} - {JSON.stringify(oldJSON)} to{" "} - {JSON.stringify(newJSON)} + Setting {summary.propertyKey} changed from{" "} + {renderProperty(summary.oldValue)} to{" "} + {renderProperty(summary.newValue)} ); } @@ -312,6 +329,16 @@ for (const command of [ return Ok({renderSettingChangeSummary(result.ok)}); }, }); + DraupnirContextToCommandContextTranslator.registerTranslation( + command, + function (draupnir: Draupnir) { + return { + protectionContext: draupnir, + protectionsManager: draupnir.protectedRoomsSet.protections, + protectedRoomsSet: draupnir.protectedRoomsSet, + }; + } + ); } function findProtectionDescriptionForCommand( @@ -326,30 +353,18 @@ function findProtectionDescriptionForCommand( return Ok(protectionDescription); } -function findSettingDescriptionForCommand( - settings: ProtectionSettings, - settingName: string -): ActionResult>> { - const setting = settings.getDescription(settingName); - if (setting === undefined) { - return ActionError.Result( - `Unable to find a protection setting named ${settingName}` - ); - } - return Ok(setting); -} - interface SettingDetails< - TSettings extends UnknownSettings = UnknownSettings, + TConfig extends UnknownConfig = UnknownConfig, + Key extends keyof EDStatic = keyof EDStatic, > { - readonly protectionDescription: ProtectionDescription; - readonly protectionSettings: ProtectionSettings; - readonly settingDescription: ProtectionSetting; - readonly previousSettings: TSettings; + readonly protectionDescription: ProtectionDescription; + readonly previousSettings: EDStatic; + readonly propertyKey: Key; + readonly description: ConfigDescription; } async function findSettingDetailsForCommand( - draupnir: Draupnir, + protectionsManager: ProtectionsManager, protectionName: string, settingName: string ): Promise> { @@ -359,50 +374,52 @@ async function findSettingDetailsForCommand( return protectionDescription; } const settingsDescription = protectionDescription.ok.protectionSettings; - const settingDescription = findSettingDescriptionForCommand( - settingsDescription, - settingName + const previousSettings = await protectionsManager.getProtectionSettings( + protectionDescription.ok ); - if (isError(settingDescription)) { - return settingDescription; - } - const previousSettings = - await draupnir.protectedRoomsSet.protections.getProtectionSettings( - protectionDescription.ok - ); if (isError(previousSettings)) { return previousSettings; } return Ok({ protectionDescription: protectionDescription.ok, - protectionSettings: settingsDescription, - settingDescription: settingDescription.ok, + propertyKey: + settingName as keyof typeof settingsDescription.schema.properties, + description: protectionDescription.ok.protectionSettings, previousSettings: previousSettings.ok, }); } +// So I'm thinking instead that we're going to move to the PersistentConfigData +// thingy for protection settings. Wouldn't it make sense to make a plan for that, +// consider how recovery would work, and how to unit test evertyhing, then +// do that. + async function changeSettingsForCommands< - TSettings extends UnknownSettings = UnknownSettings, + ProtectionContext = unknown, + TConfig extends UnknownConfig = UnknownConfig, >( - draupnir: Draupnir, - details: SettingDetails, + context: ProtectionContext, + protectedRoomsSet: ProtectedRoomsSet, + protectionsManager: ProtectionsManager, + details: SettingDetails, settingName: string, - newSettings: TSettings -): Promise> { + newSettings: EDStatic +): Promise>> { const changeResult = - await draupnir.protectedRoomsSet.protections.changeProtectionSettings( - details.protectionDescription, - draupnir.protectedRoomsSet, - draupnir, + await protectedRoomsSet.protections.changeProtectionSettings( + details.protectionDescription as unknown as ProtectionDescription, + protectedRoomsSet, + context, newSettings ); if (isError(changeResult)) { return changeResult; } return Ok({ - description: details.settingDescription, - oldValue: details.previousSettings[settingName], - newValue: newSettings[settingName], + description: details.description, + oldValue: details.previousSettings[settingName as keyof EDStatic], + newValue: newSettings[settingName as keyof EDStatic], + propertyKey: settingName as keyof EDStatic, }); } diff --git a/src/commands/ProtectionsShowCommand.tsx b/src/commands/ProtectionsShowCommand.tsx new file mode 100644 index 00000000..39e21056 --- /dev/null +++ b/src/commands/ProtectionsShowCommand.tsx @@ -0,0 +1,152 @@ +// Copyright 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { + DeadDocumentJSX, + DocumentNode, + StringPresentationType, + describeCommand, + tuple, +} from "@the-draupnir-project/interface-manager"; +import { Draupnir } from "../Draupnir"; +import { Ok, Result, ResultError, isError } from "@gnuxie/typescript-result"; +import { + CapabilityProviderDescription, + CapabilityProviderSet, + Protection, + ProtectionDescription, + findProtection, +} from "matrix-protection-suite"; +import { DraupnirInterfaceAdaptor } from "./DraupnirCommandPrerequisites"; +import { StandardPersistentConfigRenderer } from "../safemode/PersistentConfigRenderer"; + +type ProtectionShowInfo = { + readonly description: ProtectionDescription; + readonly isEnabled: boolean; + readonly protection: Protection | undefined; + readonly config: Record; + readonly activeCapabilityProviderSet: CapabilityProviderSet; +}; + +export const DraupnirProtectionsShowCommand = describeCommand({ + summary: + "Show a description of and the configured protection settings for a protection", + parameters: tuple({ + name: "protection name", + acceptor: StringPresentationType, + }), + async executor( + draupnir: Draupnir, + _info, + _keywords, + _rest, + protectionName + ): Promise> { + const protectionDescription = findProtection(protectionName); + if (protectionDescription === undefined) { + return ResultError.Result( + `Cannot find a protection named ${protectionName}` + ); + } + const enabledProtections = + draupnir.protectedRoomsSet.protections.allProtections; + const protection = enabledProtections.find( + (protection) => protection.description === protectionDescription + ); + const settings = + await draupnir.protectedRoomsSet.protections.getProtectionSettings( + protectionDescription + ); + if (isError(settings)) { + return settings.elaborate( + `Unable to fetch the protection settings for the protection ${protectionName}` + ); + } + const capabilityProviderSet = + await draupnir.protectedRoomsSet.protections.getCapabilityProviderSet( + protectionDescription + ); + if (isError(capabilityProviderSet)) { + return capabilityProviderSet.elaborate( + `Unable to fetch the capability provider set for the protection ${protectionName}` + ); + } + return Ok({ + description: protectionDescription, + isEnabled: protection !== undefined, + protection, + config: settings.ok, + activeCapabilityProviderSet: capabilityProviderSet.ok, + }); + }, +}); + +DraupnirInterfaceAdaptor.describeRenderer(DraupnirProtectionsShowCommand, { + JSXRenderer(commandResult) { + if (isError(commandResult)) { + return Ok(undefined); + } + const protectionInfo = commandResult.ok; + return Ok( + + + {protectionInfo.description.name}{" "} + {protectionInfo.isEnabled ? "🟢 (enabled)" : "🔴 (disabled)"} + +

{protectionInfo.description.description}

+

Protection settings

+ {StandardPersistentConfigRenderer.renderConfigDocumentation( + protectionInfo.description.protectionSettings + )} + {StandardPersistentConfigRenderer.renderConfigStatus({ + description: protectionInfo.description.protectionSettings, + error: undefined, + data: protectionInfo.config, + })} +

+ To change a setting, use the{" "} + + !draupnir protections config {"<"}add/set/remove{">"}{" "} + {protectionInfo.description.name} {"<"}property name{">"} {"<"}value + {">"} + {" "} + command. Protections may provide more convienant commands to manage + their settings. +

+ +

Capability provider set

+ {renderCapabilityProviderSet( + protectionInfo.activeCapabilityProviderSet + )} +
+ ); + }, +}); + +function renderCapabilityProvider( + name: string, + capabilityProvider: CapabilityProviderDescription +): DocumentNode { + return ( + +

+ {name}: interface:{" "} + {capabilityProvider.interface.name} + provider: {capabilityProvider.name} +

+
+ ); +} + +function renderCapabilityProviderSet(set: CapabilityProviderSet): DocumentNode { + return ( + +
    + {Object.entries(set).map(([name, provider]) => ( +
  • {renderCapabilityProvider(name, provider)}
  • + ))} +
+
+ ); +} diff --git a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts index e2bf4e91..93718b4a 100644 --- a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts +++ b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts @@ -19,6 +19,7 @@ import { MjolnirEnabledProtectionsEventType, MjolnirPolicyRoomsConfig, MjolnirProtectedRoomsConfig, + MjolnirProtectionSettingsConfig, MjolnirProtectionSettingsEventType, MjolnirProtectionsConfig, Ok, @@ -33,6 +34,7 @@ import { RoomStateManager, StandardProtectedRoomsManager, StandardProtectedRoomsSet, + StandardProtectionCapabilityProviderSetConfig, StandardProtectionsManager, StandardSetMembership, StandardSetRoomState, @@ -40,7 +42,7 @@ import { } from "matrix-protection-suite"; import { BotSDKAccountDataConfigBackend, - BotSDKMatrixStateData, + BotSDKRoomStateConfigBackend, MatrixSendClient, } from "matrix-protection-suite-for-matrix-bot-sdk"; import { DefaultEnabledProtectionsMigration } from "../protections/DefaultEnabledProtectionsMigration"; @@ -134,10 +136,16 @@ async function makeProtectionsManager( return Ok( new StandardProtectionsManager( protectionsConfigResult.ok, - new BotSDKMatrixStateData( - MjolnirProtectionSettingsEventType, - result.ok, - client + new StandardProtectionCapabilityProviderSetConfig(), + new MjolnirProtectionSettingsConfig((description) => + Ok( + new BotSDKRoomStateConfigBackend( + client, + managementRoom.toRoomIDOrAlias(), + MjolnirProtectionSettingsEventType, + description.name + ) + ) ) ) ); diff --git a/src/protections/BanPropagation.tsx b/src/protections/BanPropagation.tsx index 72816b52..b99c119f 100644 --- a/src/protections/BanPropagation.tsx +++ b/src/protections/BanPropagation.tsx @@ -33,9 +33,9 @@ import { Task, describeProtection, isError, - UnknownSettings, UserConsequences, Membership, + UnknownConfig, } from "matrix-protection-suite"; import { Draupnir } from "../Draupnir"; import { resolveRoomReferenceSafe } from "matrix-protection-suite-for-matrix-bot-sdk"; @@ -203,7 +203,7 @@ export type BanPropagationProtectionCapabilities = { export type BanPropagationProtectionCapabilitiesDescription = ProtectionDescription< Draupnir, - UnknownSettings, + UnknownConfig, BanPropagationProtectionCapabilities >; diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index 3c130581..d983f981 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -20,29 +20,32 @@ import { LogLevel } from "matrix-bot-sdk"; import { AbstractProtection, ActionResult, + EDStatic, EventConsequences, Logger, Ok, ProtectedRoomsSet, ProtectionDescription, RoomEvent, - SafeIntegerProtectionSetting, - StandardProtectionSettings, UserConsequences, describeProtection, isError, } from "matrix-protection-suite"; +import { Type } from "@sinclair/typebox"; const log = new Logger("BasicFloodingProtection"); -type BasicFloodingProtectionSettings = { - maxPerMinute: number; -}; - // if this is exceeded, we'll ban the user for spam and redact their messages export const DEFAULT_MAX_PER_MINUTE = 10; const TIMESTAMP_THRESHOLD = 30000; // 30s out of phase +const BasicFloodingProtectionSettings = Type.Object({ + maxPerMinute: Type.Integer({ default: DEFAULT_MAX_PER_MINUTE }), +}); +type BasicFloodingProtectionSettings = EDStatic< + typeof BasicFloodingProtectionSettings +>; + export type BasicFloodingProtectionCapabilities = { userConsequences: UserConsequences; eventConsequences: EventConsequences; @@ -50,14 +53,14 @@ export type BasicFloodingProtectionCapabilities = { export type BasicFloodingProtectionDescription = ProtectionDescription< Draupnir, - BasicFloodingProtectionSettings, + typeof BasicFloodingProtectionSettings, BasicFloodingProtectionCapabilities >; describeProtection< BasicFloodingProtectionCapabilities, Draupnir, - BasicFloodingProtectionSettings + typeof BasicFloodingProtectionSettings >({ name: "BasicFloodingProtection", description: `If a user posts more than ${DEFAULT_MAX_PER_MINUTE} messages in 60s they'll be @@ -71,6 +74,7 @@ describeProtection< userConsequences: "StandardUserConsequences", eventConsequences: "StandardEventConsequences", }, + configSchema: BasicFloodingProtectionSettings, factory: ( description, protectedRoomsSet, @@ -79,7 +83,7 @@ describeProtection< rawSettings ) => { const parsedSettings = - description.protectionSettings.parseSettings(rawSettings); + description.protectionSettings.parseConfig(rawSettings); if (isError(parsedSettings)) { return parsedSettings; } @@ -89,19 +93,10 @@ describeProtection< capabilities, protectedRoomsSet, draupnir, - parsedSettings.ok + parsedSettings.ok.maxPerMinute ) ); }, - protectionSettings: - new StandardProtectionSettings( - { - maxPerMinute: new SafeIntegerProtectionSetting("maxPerMinute"), - }, - { - maxPerMinute: DEFAULT_MAX_PER_MINUTE, - } - ), }); type LastEvents = { originServerTs: number; eventID: StringEventID }[]; @@ -159,7 +154,7 @@ export class BasicFloodingProtection capabilities: BasicFloodingProtectionCapabilities, protectedRoomsSet: ProtectedRoomsSet, private readonly draupnir: Draupnir, - private readonly settings: BasicFloodingProtectionSettings + private readonly maxPerMinute: number ) { super(description, capabilities, protectedRoomsSet, {}); this.userConsequences = capabilities.userConsequences; @@ -199,7 +194,7 @@ export class BasicFloodingProtection messageCount++; } - if (messageCount >= this.settings.maxPerMinute) { + if (messageCount >= this.maxPerMinute) { await this.draupnir.managementRoomOutput.logMessage( LogLevel.WARN, "BasicFlooding", @@ -250,8 +245,8 @@ export class BasicFloodingProtection } // Trim the oldest messages off the user's history if it's getting large - if (forUser.length > this.settings.maxPerMinute * 2) { - forUser.splice(0, forUser.length - this.settings.maxPerMinute * 2 - 1); + if (forUser.length > this.maxPerMinute * 2) { + forUser.splice(0, forUser.length - this.maxPerMinute * 2 - 1); } return Ok(undefined); } diff --git a/src/protections/FirstMessageIsImage.ts b/src/protections/FirstMessageIsImage.ts index 4fb5e7b4..cf3075ad 100644 --- a/src/protections/FirstMessageIsImage.ts +++ b/src/protections/FirstMessageIsImage.ts @@ -22,6 +22,7 @@ import { RoomEvent, RoomMembershipRevision, RoomMessage, + UnknownConfig, UserConsequences, Value, describeProtection, @@ -33,8 +34,6 @@ import { StringUserID, } from "@the-draupnir-project/matrix-basic-types"; -type FirstMessageIsImageProtectionSettings = Record; - export type FirstMessageIsImageProtectionCapabilities = { userConsequences: UserConsequences; eventConsequences: EventConsequences; @@ -42,15 +41,11 @@ export type FirstMessageIsImageProtectionCapabilities = { export type FirstMessageIsImageProtectionDescription = ProtectionDescription< Draupnir, - FirstMessageIsImageProtectionSettings, + UnknownConfig, FirstMessageIsImageProtectionCapabilities >; -describeProtection< - FirstMessageIsImageProtectionCapabilities, - Draupnir, - FirstMessageIsImageProtectionSettings ->({ +describeProtection({ name: "FirstMessageIsImageProtection", description: "If the first thing a user does after joining is to post an image or video, \ diff --git a/src/protections/JoinWaveShortCircuit.tsx b/src/protections/JoinWaveShortCircuit.tsx index 22a0a119..a7d258f2 100644 --- a/src/protections/JoinWaveShortCircuit.tsx +++ b/src/protections/JoinWaveShortCircuit.tsx @@ -12,6 +12,7 @@ import { AbstractProtection, ActionResult, CapabilitySet, + EDStatic, Logger, MembershipChange, MembershipChangeType, @@ -19,8 +20,6 @@ import { ProtectedRoomsSet, ProtectionDescription, RoomMembershipRevision, - SafeIntegerProtectionSetting, - StandardProtectionSettings, describeProtection, isError, } from "matrix-protection-suite"; @@ -28,6 +27,7 @@ import { LogLevel } from "matrix-bot-sdk"; import { Draupnir } from "../Draupnir"; import { DraupnirProtection } from "./Protection"; import { StringRoomID } from "@the-draupnir-project/matrix-basic-types"; +import { Type } from "@sinclair/typebox"; const log = new Logger("JoinWaveShortCircuitProtection"); @@ -35,30 +35,35 @@ const DEFAULT_MAX_PER_TIMESCALE = 50; const DEFAULT_TIMESCALE_MINUTES = 60; const ONE_MINUTE = 60_000; // 1min in ms -type JoinWaveShortCircuitProtectionSettings = { - maxPer: number; - timescaleMinutes: number; -}; +const JoinWaveShortCircuitProtectionSettings = Type.Object({ + maxPer: Type.Integer({ default: DEFAULT_MAX_PER_TIMESCALE }), + timescaleMinutes: Type.Integer({ default: DEFAULT_TIMESCALE_MINUTES }), +}); + +type JoinWaveShortCircuitProtectionSettings = EDStatic< + typeof JoinWaveShortCircuitProtectionSettings +>; // TODO: Add join rule capability. type JoinWaveShortCircuitProtectionCapabilities = Record; type JoinWaveShortCircuitProtectionDescription = ProtectionDescription< Draupnir, - JoinWaveShortCircuitProtectionSettings, + typeof JoinWaveShortCircuitProtectionSettings, JoinWaveShortCircuitProtectionCapabilities >; describeProtection< JoinWaveShortCircuitProtectionCapabilities, Draupnir, - JoinWaveShortCircuitProtectionSettings + typeof JoinWaveShortCircuitProtectionSettings >({ name: "JoinWaveShortCircuitProtection", description: "If X amount of users join in Y time, set the room to invite-only.", capabilityInterfaces: {}, defaultCapabilities: {}, + configSchema: JoinWaveShortCircuitProtectionSettings, factory: function ( description, protectedRoomsSet, @@ -66,8 +71,7 @@ describeProtection< capabilities, settings ) { - const parsedSettings = - description.protectionSettings.parseSettings(settings); + const parsedSettings = description.protectionSettings.parseConfig(settings); if (isError(parsedSettings)) { return parsedSettings; } @@ -81,16 +85,6 @@ describeProtection< ) ); }, - protectionSettings: new StandardProtectionSettings( - { - maxPer: new SafeIntegerProtectionSetting("maxPer"), - timescaleMinutes: new SafeIntegerProtectionSetting("timescaleMinutes"), - }, - { - maxPer: DEFAULT_MAX_PER_TIMESCALE, - timescaleMinutes: DEFAULT_TIMESCALE_MINUTES, - } - ), }); export class JoinWaveShortCircuitProtection diff --git a/src/protections/MentionLimitProtection.ts b/src/protections/MentionLimitProtection.ts index 3a68a655..fed3ba69 100644 --- a/src/protections/MentionLimitProtection.ts +++ b/src/protections/MentionLimitProtection.ts @@ -12,7 +12,7 @@ import { Protection, ProtectionDescription, RoomEvent, - UnknownSettings, + UnknownConfig, Value, describeProtection, } from "matrix-protection-suite"; @@ -67,7 +67,7 @@ export function isContainingMentionsOverLimit( export type MentionLimitProtectionDescription = ProtectionDescription< unknown, - UnknownSettings, + UnknownConfig, MentionLimitProtectionCapabilities >; diff --git a/src/protections/MessageIsMedia.ts b/src/protections/MessageIsMedia.ts index d0455a24..265ea4d6 100644 --- a/src/protections/MessageIsMedia.ts +++ b/src/protections/MessageIsMedia.ts @@ -19,6 +19,7 @@ import { ProtectionDescription, RoomEvent, RoomMessage, + UnknownConfig, Value, describeProtection, } from "matrix-protection-suite"; @@ -29,7 +30,7 @@ import { userServerName, } from "@the-draupnir-project/matrix-basic-types"; -type MessageIsMediaProtectionSettings = Record; +type MessageIsMediaProtectionSettings = UnknownConfig; type MessageIsMediaCapabilities = { eventConsequences: EventConsequences; @@ -41,11 +42,7 @@ type MessageIsMediaProtectionDescription = ProtectionDescription< MessageIsMediaCapabilities >; -describeProtection< - MessageIsMediaCapabilities, - Draupnir, - MessageIsMediaProtectionSettings ->({ +describeProtection({ name: "MessageIsMediaProtection", description: "If a user posts an image or video, that message will be redacted. No bans are issued.", diff --git a/src/protections/MessageIsVoice.ts b/src/protections/MessageIsVoice.ts index d7b067ca..308ea1e5 100644 --- a/src/protections/MessageIsVoice.ts +++ b/src/protections/MessageIsVoice.ts @@ -19,6 +19,7 @@ import { ProtectionDescription, RoomEvent, RoomMessage, + UnknownConfig, Value, describeProtection, } from "matrix-protection-suite"; @@ -33,7 +34,7 @@ type MessageIsVoiceCapabilities = { eventConsequences: EventConsequences; }; -type MessageIsVoiceSettings = Record; +type MessageIsVoiceSettings = UnknownConfig; type MessageIsVoiceDescription = ProtectionDescription< Draupnir, @@ -41,11 +42,7 @@ type MessageIsVoiceDescription = ProtectionDescription< MessageIsVoiceCapabilities >; -describeProtection< - MessageIsVoiceCapabilities, - Draupnir, - MessageIsVoiceSettings ->({ +describeProtection({ name: "MessageIsVoiceProtection", description: "If a user posts a voice message, that message will be redacted", capabilityInterfaces: { diff --git a/src/protections/NewJoinerProtection.ts b/src/protections/NewJoinerProtection.ts index 105ebe23..07942122 100644 --- a/src/protections/NewJoinerProtection.ts +++ b/src/protections/NewJoinerProtection.ts @@ -14,7 +14,7 @@ import { Protection, ProtectionDescription, RoomMembershipRevision, - UnknownSettings, + UnknownConfig, UserConsequences, describeProtection, isError, @@ -25,7 +25,7 @@ import { userServerName } from "@the-draupnir-project/matrix-basic-types"; export type NewJoinerProtectionDescription = ProtectionDescription< unknown, - UnknownSettings, + UnknownConfig, NewJoinerProtectionCapabilities >; diff --git a/src/protections/PolicyChangeNotification.tsx b/src/protections/PolicyChangeNotification.tsx index ab8539d8..fcba713f 100644 --- a/src/protections/PolicyChangeNotification.tsx +++ b/src/protections/PolicyChangeNotification.tsx @@ -19,7 +19,7 @@ import { PolicyRuleChange, ProtectedRoomsSet, ProtectionDescription, - UnknownSettings, + UnknownConfig, describeProtection, isError, } from "matrix-protection-suite"; @@ -46,7 +46,7 @@ export type PolicyChangeNotificationCapabilitites = Record; export type PolicyChangeNotificationProtectionDescription = ProtectionDescription< Draupnir, - UnknownSettings, + UnknownConfig, PolicyChangeNotificationCapabilitites >; diff --git a/src/protections/TrustedReporters.ts b/src/protections/TrustedReporters.ts index 40ff2143..d92ce5ef 100644 --- a/src/protections/TrustedReporters.ts +++ b/src/protections/TrustedReporters.ts @@ -11,15 +11,14 @@ import { AbstractProtection, ActionResult, + EDStatic, EventConsequences, EventReport, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, - SafeIntegerProtectionSetting, - StandardProtectionSettings, - StringUserIDSetProtectionSettings, + StringUserIDSchema, UserConsequences, describeProtection, isError, @@ -29,15 +28,36 @@ import { StringUserID, StringEventID, } from "@the-draupnir-project/matrix-basic-types"; +import { Type } from "@sinclair/typebox"; const MAX_REPORTED_EVENT_BACKLOG = 20; -type TrustedReportersProtectionSettings = { - mxids: Set; - alertThreshold: number; - redactThreshold: number; - banThreshold: number; -}; +const TrustedReportersProtectionSettings = Type.Object({ + mxids: Type.Array(StringUserIDSchema, { + default: [], + uniqueItems: true, + description: "The users who are marked as trusted reporters.", + }), + alertThreshold: Type.Integer({ + default: 3, + description: + "The number of reports from trusted reporters required before sending an alert to the management room.", + }), + redactThreshold: Type.Integer({ + default: -1, + description: + "The number of reports from trusted reporters required before the relevant event is redacted.", + }), + banThreshold: Type.Integer({ + default: -1, + description: + "The number of reports from trusted reporters required before the reported user banned.", + }), +}); + +type TrustedReportersProtectionSettings = EDStatic< + typeof TrustedReportersProtectionSettings +>; type TrustedReportersCapabilities = { userConsequences: UserConsequences; @@ -46,14 +66,14 @@ type TrustedReportersCapabilities = { type TrustedReportersDescription = ProtectionDescription< Draupnir, - TrustedReportersProtectionSettings, + typeof TrustedReportersProtectionSettings, TrustedReportersCapabilities >; describeProtection< TrustedReportersCapabilities, Draupnir, - TrustedReportersProtectionSettings + typeof TrustedReportersProtectionSettings >({ name: "TrustedReporters", description: @@ -66,6 +86,7 @@ describeProtection< userConsequences: "StandardUserConsequences", eventConsequences: "StandardEventConsequences", }, + configSchema: TrustedReportersProtectionSettings, factory: function ( description, protectedRoomsSet, @@ -74,7 +95,7 @@ describeProtection< rawSettings ) { const parsedSettings = - description.protectionSettings.parseSettings(rawSettings); + description.protectionSettings.parseConfig(rawSettings); if (isError(parsedSettings)) { return parsedSettings; } @@ -88,22 +109,6 @@ describeProtection< ) ); }, - protectionSettings: - new StandardProtectionSettings( - { - mxids: new StringUserIDSetProtectionSettings("mxids"), - alertThreshold: new SafeIntegerProtectionSetting("alertThreshold"), - redactThreshold: new SafeIntegerProtectionSetting("redactThreshold"), - banThreshold: new SafeIntegerProtectionSetting("banThreshold"), - }, - { - mxids: new Set(), - alertThreshold: 3, - // -1 means 'disabled' - redactThreshold: -1, - banThreshold: -1, - } - ), }); /* @@ -115,7 +120,7 @@ export class TrustedReporters implements Protection { private recentReported = new Map>(); - + private readonly reporters = new Set(); private readonly userConsequences: UserConsequences; private readonly eventConsequences: EventConsequences; public constructor( @@ -128,12 +133,13 @@ export class TrustedReporters super(description, capabilities, protectedRoomsSet, {}); this.userConsequences = capabilities.userConsequences; this.eventConsequences = capabilities.eventConsequences; + this.reporters = new Set(settings.mxids); } public async handleEventReport( report: EventReport ): Promise> { - if (!this.settings.mxids.has(report.sender)) { + if (!this.reporters.has(report.sender)) { // not a trusted user, we're not interested return Ok(undefined); } diff --git a/src/protections/WordList.ts b/src/protections/WordList.ts index 6777b05e..f75f8683 100644 --- a/src/protections/WordList.ts +++ b/src/protections/WordList.ts @@ -22,6 +22,7 @@ import { RoomEvent, RoomMembershipRevision, RoomMessage, + UnknownConfig, UserConsequences, Value, describeProtection, @@ -40,7 +41,7 @@ type WordListCapabilities = { eventConsequences: EventConsequences; }; -type WordListSettings = Record; +type WordListSettings = UnknownConfig; type WordListDescription = ProtectionDescription< Draupnir, @@ -48,7 +49,7 @@ type WordListDescription = ProtectionDescription< WordListCapabilities >; -describeProtection({ +describeProtection({ name: "WordListProtection", description: "If a user posts a monitored word a set amount of time after joining, they\ diff --git a/src/protections/invitation/JoinRoomsOnInviteProtection.tsx b/src/protections/invitation/JoinRoomsOnInviteProtection.tsx index e68954be..678bc827 100644 --- a/src/protections/invitation/JoinRoomsOnInviteProtection.tsx +++ b/src/protections/invitation/JoinRoomsOnInviteProtection.tsx @@ -19,6 +19,7 @@ import { ProtectionDescription, StandardDeduplicator, Task, + UnknownConfig, describeProtection, isError, } from "matrix-protection-suite"; @@ -46,7 +47,7 @@ import { sendMatrixEventsFromDeadDocument } from "../../commands/interface-manag const log = new Logger("JoinRoomsOnInviteProtection"); export type JoinRoomsOnInviteProtectionCapabilities = Record; -export type JoinRoomsOnInviteProtectionSettings = Record; +export type JoinRoomsOnInviteProtectionSettings = UnknownConfig; export type JoinRoomsOnInviteProtectionDescription = ProtectionDescription< Draupnir, diff --git a/src/safemode/PersistentConfigRenderer.tsx b/src/safemode/PersistentConfigRenderer.tsx index 1cf987ed..ae1a4b9e 100644 --- a/src/safemode/PersistentConfigRenderer.tsx +++ b/src/safemode/PersistentConfigRenderer.tsx @@ -32,6 +32,7 @@ const ConfigStatusIndicator = Object.freeze({ export interface PersistentConfigRenderer { renderConfigStatus(config: PersistentConfigStatus): DocumentNode; renderAdaptorStatus(info: PersistentConfigStatus[]): DocumentNode; + renderConfigDocumentation(description: ConfigDescription): DocumentNode; } function findError( @@ -65,6 +66,15 @@ function renderConfigPropertyValue(value: unknown): DocumentNode { return renderRoomPill(value); } else if (value instanceof MatrixUserID) { return renderMentionPill(value.toString(), value.toString()); + } else if (Array.isArray(value)) { + if (value.length === 0) { + return ( + + [] (empty array) + + ); + } + return
    {value.map((value) => renderConfigPropertyValue(value))}
; } else { return ( @@ -177,6 +187,25 @@ function renderBodgedConfig(config: PersistentConfigStatus): DocumentNode { ); } +function renderConfigDocumentation( + description: ConfigDescription +): DocumentNode { + return ( + +

{description.schema.description ?? "No description"}

+
    + {description.properties().map((property) => ( +
  • + {property.name}:{" "} + {property.description ?? "No description"} +

    default value: {renderConfigPropertyValue(property.default)}

    +
  • + ))} +
+
+ ); +} + export const StandardPersistentConfigRenderer = Object.freeze({ renderConfigStatus(config: PersistentConfigStatus): DocumentNode { if (typeof config.data !== "object" || config.data === null) { @@ -185,7 +214,7 @@ export const StandardPersistentConfigRenderer = Object.freeze({ return renderConfigDetails( config.error, config.description, - +
    {config.description .properties() .map((property) => @@ -195,7 +224,7 @@ export const StandardPersistentConfigRenderer = Object.freeze({ config.error?.errors ?? [] ) )} - +
); }, renderAdaptorStatus(info: PersistentConfigStatus[]): DocumentNode { @@ -210,4 +239,5 @@ export const StandardPersistentConfigRenderer = Object.freeze({
); }, + renderConfigDocumentation, }) satisfies PersistentConfigRenderer; diff --git a/test/integration/reportPollingTest.ts b/test/integration/reportPollingTest.ts index afde0f8a..8cc9c848 100644 --- a/test/integration/reportPollingTest.ts +++ b/test/integration/reportPollingTest.ts @@ -16,13 +16,14 @@ import { Ok, Protection, ProtectionDescription, - StandardProtectionSettings, Task, + describeConfig, } from "matrix-protection-suite"; import { MatrixRoomReference, StringRoomID, } from "@the-draupnir-project/matrix-basic-types"; +import { Type } from "@sinclair/typebox"; describe("Test: Report polling", function () { let client: MatrixClient; @@ -77,13 +78,12 @@ describe("Test: Report polling", function () { requiredStatePermissions: [], }); }, - protectionSettings: new StandardProtectionSettings({}, {}), + protectionSettings: describeConfig({ schema: Type.Object({}) }), }; void Task( (async () => { await draupnir.protectedRoomsSet.protections.addProtection( testProtectionDescription, - {}, draupnir.protectedRoomsSet, draupnir ); diff --git a/yarn.lock b/yarn.lock index 7046de58..0959063f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2645,17 +2645,17 @@ matrix-appservice@^2.0.0: request-promise "^4.2.6" sanitize-html "^2.11.0" -"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@1.7.0": - version "1.7.0" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-1.7.0.tgz#ce195b8165f552de10ec42d536635fd494ccf302" - integrity sha512-SBJi2J7Aot0lO4ycZWm74sVY51pCY7e1KcAtX1KiRqQgfKlWym0JyJ0CQSW2WgmLtRjOGjhe3u7/V2T0kgQJlw== +"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-2.0.0.tgz#4c5256e99f575bbff53392f1e04008edffeefe32" + integrity sha512-V03teGAAPLcIwrVcGz6GALSWIJ+fuJ8vWI8jfKI6OyE5YLuQcXxHUagsoyYZRE/mmKfLGmHA+5woZcXAbnDfEQ== dependencies: "@gnuxie/typescript-result" "^1.0.0" -"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@1.7.0": - version "1.7.0" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-1.7.0.tgz#1c7439c58fc794a331ff2e6dc5a6381f0f5d187e" - integrity sha512-xT8DPxcmKpvJ4qX98aZE5HSH2B9VfcQWdWISldmu6Z4iy54DV5bAmSGFqAYRRs1xWxPuXtVNsHs+LrBo5U3CdQ== +"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-2.0.0.tgz#423e205919eaac7f9edbae4ddc7dac266dd4119b" + integrity sha512-fjpfKtQKuBrZTvWcpeNbpJcU6zqN9DKKjKqfEoz9jeJ/uwj/nQ6ZtrxRjiQxVHJV3/b3vJKUEqu796IyEDhDEQ== dependencies: "@gnuxie/typescript-result" "^1.0.0" await-lock "^2.2.2"