From 9ff57ac4790292b42d2a854f2fc01fcba32df1c1 Mon Sep 17 00:00:00 2001 From: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:49:02 -0700 Subject: [PATCH] refactor(exec): unify channel approvals and restore routing/auth (#57838) * fix(exec): add shared approval runtime * fix(exec): harden shared approval runtime * fix(exec): guard approval expiration callbacks * fix(exec): handle approval runtime races * fix(exec): clean up failed approval deliveries * fix(exec): restore channel approval routing * fix(exec): scope telegram legacy approval fallback * refactor(exec): centralize native approval delivery * fix(exec): harden approval auth and account routing * test(exec): align telegram approval auth assertions * fix(exec): align approval rebase followups * fix(exec): clarify plugin approval not-found errors * fix(exec): fall back to session-bound telegram accounts * fix(exec): detect structured telegram approval misses * test(exec): align discord approval auth coverage * fix(exec): ignore discord dm origin channel routes * fix(telegram): skip self-authored message echoes * fix(exec): keep implicit approval auth non-explicit --- CHANGELOG.md | 1 + docs/.generated/plugin-sdk-api-baseline.json | 34 +- docs/.generated/plugin-sdk-api-baseline.jsonl | 34 +- .../discord/src/approval-native.test.ts | 100 ++ extensions/discord/src/approval-native.ts | 188 +++ extensions/discord/src/channel.ts | 24 +- extensions/discord/src/exec-approvals.ts | 23 +- .../src/monitor/exec-approvals.test.ts | 482 ++----- .../discord/src/monitor/exec-approvals.ts | 623 ++++----- extensions/telegram/src/approval-native.ts | 170 +++ extensions/telegram/src/bot-deps.ts | 5 + .../telegram/src/bot-handlers.runtime.ts | 204 +-- .../bot.create-telegram-bot.test-harness.ts | 9 + .../src/bot.create-telegram-bot.test.ts | 31 + extensions/telegram/src/bot.test.ts | 261 ++++ extensions/telegram/src/button-types.test.ts | 85 ++ extensions/telegram/src/channel.ts | 29 +- .../src/exec-approval-resolver.test.ts | 129 ++ .../telegram/src/exec-approval-resolver.ts | 114 ++ .../src/exec-approvals-handler.test.ts | 164 ++- .../telegram/src/exec-approvals-handler.ts | 375 +++--- src/auto-reply/reply/commands-approve.ts | 103 +- src/auto-reply/reply/commands.test.ts | 1181 ++++------------- src/channels/plugins/types.adapters.ts | 43 + src/infra/approval-native-delivery.test.ts | 147 ++ src/infra/approval-native-delivery.ts | 134 ++ src/infra/channel-approval-auth.test.ts | 25 +- src/infra/channel-approval-auth.ts | 40 +- .../exec-approval-channel-runtime.test.ts | 437 ++++++ src/infra/exec-approval-channel-runtime.ts | 285 ++++ src/infra/exec-approval-reply.test.ts | 75 ++ src/infra/exec-approval-reply.ts | 117 +- .../approval-delivery-helpers.test.ts | 20 + src/plugin-sdk/approval-delivery-helpers.ts | 48 + src/plugin-sdk/infra-runtime.ts | 2 + 35 files changed, 3606 insertions(+), 2136 deletions(-) create mode 100644 extensions/discord/src/approval-native.test.ts create mode 100644 extensions/discord/src/approval-native.ts create mode 100644 extensions/telegram/src/approval-native.ts create mode 100644 extensions/telegram/src/button-types.test.ts create mode 100644 extensions/telegram/src/exec-approval-resolver.test.ts create mode 100644 extensions/telegram/src/exec-approval-resolver.ts create mode 100644 src/infra/approval-native-delivery.test.ts create mode 100644 src/infra/approval-native-delivery.ts create mode 100644 src/infra/exec-approval-channel-runtime.test.ts create mode 100644 src/infra/exec-approval-channel-runtime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bc6f319cd9..df5bb2226fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -314,6 +314,7 @@ Docs: https://docs.openclaw.ai - Security/path resolution: prefer non-user-writable absolute helper binaries for OpenClaw CLI, ffmpeg, and OpenSSL resolution so PATH hijacks cannot replace trusted helpers with attacker-controlled executables. - Security/gateway command scopes: require `operator.admin` before Telegram target writeback and Talk Voice `/voice set` config writes persist through gateway message flows. - Security/OpenShell mirror: exclude workspace `hooks/` from mirror sync so untrusted sandbox files cannot become trusted host hooks on gateway startup. +- Exec approvals/channels: unify Discord and Telegram exec approval runtime handling, move approval buttons onto the shared interactive reply model, and fix Telegram approval buttons and typed `/approve` commands so configured approvers can resolve requests reliably again. (#57516) Thanks @scoootscooob. ## 2026.3.24-beta.2 diff --git a/docs/.generated/plugin-sdk-api-baseline.json b/docs/.generated/plugin-sdk-api-baseline.json index 8b4325a111b..1701b13570d 100644 --- a/docs/.generated/plugin-sdk-api-baseline.json +++ b/docs/.generated/plugin-sdk-api-baseline.json @@ -109,7 +109,7 @@ "exportName": "ChannelConfiguredBindingConversationRef", "kind": "type", "source": { - "line": 607, + "line": 650, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -118,7 +118,7 @@ "exportName": "ChannelConfiguredBindingMatch", "kind": "type", "source": { - "line": 612, + "line": 655, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -127,7 +127,7 @@ "exportName": "ChannelConfiguredBindingProvider", "kind": "type", "source": { - "line": 628, + "line": 671, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -478,7 +478,7 @@ "exportName": "ReplyPayload", "kind": "type", "source": { - "line": 76, + "line": 79, "path": "src/auto-reply/types.ts" } }, @@ -1206,7 +1206,7 @@ "exportName": "ChannelCommandConversationContext", "kind": "type", "source": { - "line": 616, + "line": 659, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1500,7 +1500,7 @@ "exportName": "enqueueSystemEvent", "kind": "function", "source": { - "line": 91, + "line": 93, "path": "src/infra/system-events.ts" } }, @@ -1644,7 +1644,7 @@ "exportName": "resetSystemEventsForTest", "kind": "function", "source": { - "line": 157, + "line": 160, "path": "src/infra/system-events.ts" } }, @@ -1824,7 +1824,7 @@ "exportName": "ChannelAllowlistAdapter", "kind": "type", "source": { - "line": 551, + "line": 594, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1833,7 +1833,7 @@ "exportName": "ChannelApprovalAdapter", "kind": "type", "source": { - "line": 546, + "line": 588, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1914,7 +1914,7 @@ "exportName": "ChannelCommandConversationContext", "kind": "type", "source": { - "line": 616, + "line": 659, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1932,7 +1932,7 @@ "exportName": "ChannelConfiguredBindingConversationRef", "kind": "type", "source": { - "line": 607, + "line": 650, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1941,7 +1941,7 @@ "exportName": "ChannelConfiguredBindingMatch", "kind": "type", "source": { - "line": 612, + "line": 655, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1950,7 +1950,7 @@ "exportName": "ChannelConfiguredBindingProvider", "kind": "type", "source": { - "line": 628, + "line": 671, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1959,7 +1959,7 @@ "exportName": "ChannelConversationBindingSupport", "kind": "type", "source": { - "line": 644, + "line": 687, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2319,7 +2319,7 @@ "exportName": "ChannelSecurityAdapter", "kind": "type", "source": { - "line": 675, + "line": 718, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -5478,7 +5478,7 @@ "exportName": "peekSystemEvents", "kind": "function", "source": { - "line": 139, + "line": 142, "path": "src/infra/system-events.ts" } }, @@ -5532,7 +5532,7 @@ "exportName": "resetSystemEventsForTest", "kind": "function", "source": { - "line": 157, + "line": 160, "path": "src/infra/system-events.ts" } }, diff --git a/docs/.generated/plugin-sdk-api-baseline.jsonl b/docs/.generated/plugin-sdk-api-baseline.jsonl index 621dbc4a9ba..d2895ae3367 100644 --- a/docs/.generated/plugin-sdk-api-baseline.jsonl +++ b/docs/.generated/plugin-sdk-api-baseline.jsonl @@ -10,9 +10,9 @@ {"declaration":"export type ChannelCapabilities = ChannelCapabilities;","entrypoint":"index","exportName":"ChannelCapabilities","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":232,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelConfigSchema = ChannelConfigSchema;","entrypoint":"index","exportName":"ChannelConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":69,"sourcePath":"src/channels/plugins/types.plugin.ts"} {"declaration":"export type ChannelConfigUiHint = ChannelConfigUiHint;","entrypoint":"index","exportName":"ChannelConfigUiHint","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":38,"sourcePath":"src/channels/plugins/types.plugin.ts"} -{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"index","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":607,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"index","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":612,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"index","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":628,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"index","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":650,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"index","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":655,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"index","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":671,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelGatewayContext = ChannelGatewayContext;","entrypoint":"index","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":268,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelId = ChannelId;","entrypoint":"index","exportName":"ChannelId","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":13,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"index","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":526,"sourcePath":"src/channels/plugins/types.core.ts"} @@ -51,7 +51,7 @@ {"declaration":"export type ProviderAuthContext = ProviderAuthContext;","entrypoint":"index","exportName":"ProviderAuthContext","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":176,"sourcePath":"src/plugins/types.ts"} {"declaration":"export type ProviderAuthResult = ProviderAuthResult;","entrypoint":"index","exportName":"ProviderAuthResult","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":161,"sourcePath":"src/plugins/types.ts"} {"declaration":"export type ProviderRuntimeModel = ProviderRuntimeModel;","entrypoint":"index","exportName":"ProviderRuntimeModel","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":317,"sourcePath":"src/plugins/types.ts"} -{"declaration":"export type ReplyPayload = ReplyPayload;","entrypoint":"index","exportName":"ReplyPayload","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":76,"sourcePath":"src/auto-reply/types.ts"} +{"declaration":"export type ReplyPayload = ReplyPayload;","entrypoint":"index","exportName":"ReplyPayload","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":79,"sourcePath":"src/auto-reply/types.ts"} {"declaration":"export type RuntimeEnv = RuntimeEnv;","entrypoint":"index","exportName":"RuntimeEnv","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":4,"sourcePath":"src/runtime.ts"} {"declaration":"export type RuntimeLogger = RuntimeLogger;","entrypoint":"index","exportName":"RuntimeLogger","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":7,"sourcePath":"src/plugins/runtime/types-core.ts"} {"declaration":"export type SecretInput = SecretInput;","entrypoint":"index","exportName":"SecretInput","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":16,"sourcePath":"src/config/types.secrets.ts"} @@ -131,7 +131,7 @@ {"declaration":"export type BaseTokenResolution = BaseTokenResolution;","entrypoint":"channel-contract","exportName":"BaseTokenResolution","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":575,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAccountSnapshot = ChannelAccountSnapshot;","entrypoint":"channel-contract","exportName":"ChannelAccountSnapshot","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":146,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAgentTool = ChannelAgentTool;","entrypoint":"channel-contract","exportName":"ChannelAgentTool","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":18,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelCommandConversationContext = ChannelCommandConversationContext;","entrypoint":"channel-contract","exportName":"ChannelCommandConversationContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":616,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelCommandConversationContext = ChannelCommandConversationContext;","entrypoint":"channel-contract","exportName":"ChannelCommandConversationContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":659,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelGroupContext = ChannelGroupContext;","entrypoint":"channel-contract","exportName":"ChannelGroupContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":218,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"channel-contract","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":526,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"channel-contract","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":492,"sourcePath":"src/channels/plugins/types.core.ts"} @@ -163,7 +163,7 @@ {"declaration":"export function createReplyPrefixOptions(params: { cfg: OpenClawConfig; agentId: string; channel?: string | undefined; accountId?: string | undefined; }): ReplyPrefixOptions;","entrypoint":"channel-runtime","exportName":"createReplyPrefixOptions","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":64,"sourcePath":"src/channels/reply-prefix.ts"} {"declaration":"export function createTypingCallbacks(params: CreateTypingCallbacksParams): TypingCallbacks;","entrypoint":"channel-runtime","exportName":"createTypingCallbacks","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":23,"sourcePath":"src/channels/typing.ts"} {"declaration":"export function emitHeartbeatEvent(evt: Omit): void;","entrypoint":"channel-runtime","exportName":"emitHeartbeatEvent","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":51,"sourcePath":"src/infra/heartbeat-events.ts"} -{"declaration":"export function enqueueSystemEvent(text: string, options: SystemEventOptions): boolean;","entrypoint":"channel-runtime","exportName":"enqueueSystemEvent","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":91,"sourcePath":"src/infra/system-events.ts"} +{"declaration":"export function enqueueSystemEvent(text: string, options: SystemEventOptions): boolean;","entrypoint":"channel-runtime","exportName":"enqueueSystemEvent","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":93,"sourcePath":"src/infra/system-events.ts"} {"declaration":"export function getLastHeartbeatEvent(): HeartbeatEventPayload | null;","entrypoint":"channel-runtime","exportName":"getLastHeartbeatEvent","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":61,"sourcePath":"src/infra/heartbeat-events.ts"} {"declaration":"export function keepHttpServerTaskAlive(params: { server: CloseAwareServer; abortSignal?: AbortSignal | undefined; onAbort?: (() => void | Promise) | undefined; }): Promise;","entrypoint":"channel-runtime","exportName":"keepHttpServerTaskAlive","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":79,"sourcePath":"src/plugin-sdk/channel-lifecycle.ts"} {"declaration":"export function looksLikeSignalTargetId(raw: string, normalized?: string | undefined): boolean;","entrypoint":"channel-runtime","exportName":"looksLikeSignalTargetId","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":38,"sourcePath":"src/channels/plugins/normalize/signal.ts"} @@ -179,7 +179,7 @@ {"declaration":"export function recordChannelActivity(params: { channel: ChannelId; accountId?: string | null | undefined; direction: ChannelDirection; at?: number | undefined; }): void;","entrypoint":"channel-runtime","exportName":"recordChannelActivity","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":26,"sourcePath":"src/infra/channel-activity.ts"} {"declaration":"export function reduceInteractiveReply(interactive: InteractiveReply | undefined, initialState: TState, reduce: (state: TState, block: InteractiveReplyBlock, index: number) => TState): TState;","entrypoint":"channel-runtime","exportName":"reduceInteractiveReply","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":3,"sourcePath":"src/channels/plugins/outbound/interactive.ts"} {"declaration":"export function resetHeartbeatEventsForTest(): void;","entrypoint":"channel-runtime","exportName":"resetHeartbeatEventsForTest","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":65,"sourcePath":"src/infra/heartbeat-events.ts"} -{"declaration":"export function resetSystemEventsForTest(): void;","entrypoint":"channel-runtime","exportName":"resetSystemEventsForTest","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":157,"sourcePath":"src/infra/system-events.ts"} +{"declaration":"export function resetSystemEventsForTest(): void;","entrypoint":"channel-runtime","exportName":"resetSystemEventsForTest","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":160,"sourcePath":"src/infra/system-events.ts"} {"declaration":"export function resolveHeartbeatVisibility(params: { cfg: OpenClawConfig; channel: GatewayMessageChannel; accountId?: string | undefined; }): ResolvedHeartbeatVisibility;","entrypoint":"channel-runtime","exportName":"resolveHeartbeatVisibility","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":22,"sourcePath":"src/infra/heartbeat-visibility.ts"} {"declaration":"export function resolveIndicatorType(status: \"sent\" | \"ok-empty\" | \"ok-token\" | \"skipped\" | \"failed\"): HeartbeatIndicatorType | undefined;","entrypoint":"channel-runtime","exportName":"resolveIndicatorType","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":23,"sourcePath":"src/infra/heartbeat-events.ts"} {"declaration":"export function resolvePollMaxSelections(optionCount: number, allowMultiselect: boolean | undefined): number;","entrypoint":"channel-runtime","exportName":"resolvePollMaxSelections","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":29,"sourcePath":"src/polls.ts"} @@ -199,8 +199,8 @@ {"declaration":"export type ChannelAgentPromptAdapter = ChannelAgentPromptAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAgentPromptAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":465,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAgentTool = ChannelAgentTool;","entrypoint":"channel-runtime","exportName":"ChannelAgentTool","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":18,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAgentToolFactory = ChannelAgentToolFactory;","entrypoint":"channel-runtime","exportName":"ChannelAgentToolFactory","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":23,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelAllowlistAdapter = ChannelAllowlistAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAllowlistAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":551,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelApprovalAdapter = ChannelApprovalAdapter;","entrypoint":"channel-runtime","exportName":"ChannelApprovalAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":546,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelAllowlistAdapter = ChannelAllowlistAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAllowlistAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":594,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelApprovalAdapter = ChannelApprovalAdapter;","entrypoint":"channel-runtime","exportName":"ChannelApprovalAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":588,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelApprovalForwardTarget = ChannelApprovalForwardTarget;","entrypoint":"channel-runtime","exportName":"ChannelApprovalForwardTarget","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":38,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelApprovalInitiatingSurfaceState = ChannelActionAvailabilityState;","entrypoint":"channel-runtime","exportName":"ChannelApprovalInitiatingSurfaceState","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":36,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelAuthAdapter = ChannelAuthAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAuthAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":392,"sourcePath":"src/channels/plugins/types.adapters.ts"} @@ -209,12 +209,12 @@ {"declaration":"export type ChannelCapabilitiesDisplayLine = ChannelCapabilitiesDisplayLine;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDisplayLine","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":48,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelCapabilitiesDisplayTone = ChannelCapabilitiesDisplayTone;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDisplayTone","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":46,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelCommandAdapter = ChannelCommandAdapter;","entrypoint":"channel-runtime","exportName":"ChannelCommandAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":489,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelCommandConversationContext = ChannelCommandConversationContext;","entrypoint":"channel-runtime","exportName":"ChannelCommandConversationContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":616,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelCommandConversationContext = ChannelCommandConversationContext;","entrypoint":"channel-runtime","exportName":"ChannelCommandConversationContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":659,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelConfigAdapter = ChannelConfigAdapter;","entrypoint":"channel-runtime","exportName":"ChannelConfigAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":97,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":607,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":612,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":628,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConversationBindingSupport = ChannelConversationBindingSupport;","entrypoint":"channel-runtime","exportName":"ChannelConversationBindingSupport","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":644,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":650,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":655,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":671,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConversationBindingSupport = ChannelConversationBindingSupport;","entrypoint":"channel-runtime","exportName":"ChannelConversationBindingSupport","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":687,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelDirectoryAdapter = ChannelDirectoryAdapter;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":451,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelDirectoryEntry = ChannelDirectoryEntry;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryEntry","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":479,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelDirectoryEntryKind = ChannelDirectoryEntryKind;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryEntryKind","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":477,"sourcePath":"src/channels/plugins/types.core.ts"} @@ -254,7 +254,7 @@ {"declaration":"export type ChannelResolveKind = ChannelResolveKind;","entrypoint":"channel-runtime","exportName":"ChannelResolveKind","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":462,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelResolverAdapter = ChannelResolverAdapter;","entrypoint":"channel-runtime","exportName":"ChannelResolverAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":472,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelResolveResult = ChannelResolveResult;","entrypoint":"channel-runtime","exportName":"ChannelResolveResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":464,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelSecurityAdapter = ChannelSecurityAdapter;","entrypoint":"channel-runtime","exportName":"ChannelSecurityAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":675,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelSecurityAdapter = ChannelSecurityAdapter;","entrypoint":"channel-runtime","exportName":"ChannelSecurityAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":718,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelSecurityContext = ChannelSecurityContext;","entrypoint":"channel-runtime","exportName":"ChannelSecurityContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":256,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelSecurityDmPolicy = ChannelSecurityDmPolicy;","entrypoint":"channel-runtime","exportName":"ChannelSecurityDmPolicy","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":247,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"channel-runtime","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":62,"sourcePath":"src/channels/plugins/types.adapters.ts"} @@ -603,13 +603,13 @@ {"declaration":"export function isLiveTestEnabled(extraEnvVars?: readonly string[], env?: ProcessEnv): boolean;","entrypoint":"testing","exportName":"isLiveTestEnabled","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":5,"sourcePath":"src/agents/live-test-helpers.ts"} {"declaration":"export function jsonResponse(body: unknown, status?: number): Response;","entrypoint":"testing","exportName":"jsonResponse","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":1,"sourcePath":"src/test-helpers/http.ts"} {"declaration":"export function mockPinnedHostnameResolution(addresses?: string[]): Mock<(hostname: string, lookupFn?: { (hostname: string, family: number): Promise; (hostname: string, options: LookupOneOptions): Promise<...>; (hostname: string, options: LookupAllOptions): Promise<...>; (hostname: string, options: LookupOptions): Promise<...>; (hostname: string): Promise<...>; }) => Promise<...>>;","entrypoint":"testing","exportName":"mockPinnedHostnameResolution","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":4,"sourcePath":"src/test-helpers/ssrf.ts"} -{"declaration":"export function peekSystemEvents(sessionKey: string): string[];","entrypoint":"testing","exportName":"peekSystemEvents","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":139,"sourcePath":"src/infra/system-events.ts"} +{"declaration":"export function peekSystemEvents(sessionKey: string): string[];","entrypoint":"testing","exportName":"peekSystemEvents","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":142,"sourcePath":"src/infra/system-events.ts"} {"declaration":"export function primeChannelOutboundSendMock(sendMock: Mock, fallbackResult: Record, sendResults?: SendResultLike[]): void;","entrypoint":"testing","exportName":"primeChannelOutboundSendMock","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":796,"sourcePath":"src/channels/plugins/contracts/suites.ts"} {"declaration":"export function removeAckReactionAfterReply(params: { removeAfterReply: boolean; ackReactionPromise: Promise | null; ackReactionValue: string | null; remove: () => Promise; onError?: ((err: unknown) => void) | undefined; }): void;","entrypoint":"testing","exportName":"removeAckReactionAfterReply","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":81,"sourcePath":"src/channels/ack-reactions.ts"} {"declaration":"export function requestBodyText(body: BodyInit | null | undefined): string;","entrypoint":"testing","exportName":"requestBodyText","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":18,"sourcePath":"src/test-helpers/http.ts"} {"declaration":"export function requestUrl(input: string | Request | URL): string;","entrypoint":"testing","exportName":"requestUrl","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":8,"sourcePath":"src/test-helpers/http.ts"} {"declaration":"export function resetPluginRuntimeStateForTest(): void;","entrypoint":"testing","exportName":"resetPluginRuntimeStateForTest","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":193,"sourcePath":"src/plugins/runtime.ts"} -{"declaration":"export function resetSystemEventsForTest(): void;","entrypoint":"testing","exportName":"resetSystemEventsForTest","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":157,"sourcePath":"src/infra/system-events.ts"} +{"declaration":"export function resetSystemEventsForTest(): void;","entrypoint":"testing","exportName":"resetSystemEventsForTest","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":160,"sourcePath":"src/infra/system-events.ts"} {"declaration":"export function resolveProviderPluginChoice(params: { providers: ProviderPlugin[]; choice: string; }): { provider: ProviderPlugin; method: ProviderAuthMethod; wizard?: ProviderPluginWizardSetup | undefined; } | null;","entrypoint":"testing","exportName":"resolveProviderPluginChoice","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":13,"sourcePath":"src/plugins/provider-auth-choice.runtime.ts"} {"declaration":"export function runAcpRuntimeAdapterContract(params: AcpRuntimeAdapterContractParams): Promise;","entrypoint":"testing","exportName":"runAcpRuntimeAdapterContract","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":19,"sourcePath":"src/acp/runtime/adapter-contract.testkit.ts"} {"declaration":"export function sanitizeTerminalText(input: string): string;","entrypoint":"testing","exportName":"sanitizeTerminalText","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":6,"sourcePath":"src/terminal/safe-text.ts"} diff --git a/extensions/discord/src/approval-native.test.ts b/extensions/discord/src/approval-native.test.ts new file mode 100644 index 00000000000..e6109b6de9f --- /dev/null +++ b/extensions/discord/src/approval-native.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { createDiscordNativeApprovalAdapter } from "./approval-native.js"; + +describe("createDiscordNativeApprovalAdapter", () => { + it("normalizes prefixed turn-source channel ids", async () => { + const adapter = createDiscordNativeApprovalAdapter(); + + const target = await adapter.native?.resolveOriginTarget?.({ + cfg: {} as never, + accountId: "main", + approvalKind: "plugin", + request: { + id: "abc", + request: { + title: "Plugin approval", + description: "Let plugin proceed", + turnSourceChannel: "discord", + turnSourceTo: "channel:123456789", + turnSourceAccountId: "main", + }, + createdAtMs: 1, + expiresAtMs: 2, + }, + }); + + expect(target).toEqual({ to: "123456789" }); + }); + + it("falls back to approver DMs for Discord DM sessions with raw turn-source ids", async () => { + const adapter = createDiscordNativeApprovalAdapter(); + + const target = await adapter.native?.resolveOriginTarget?.({ + cfg: {} as never, + accountId: "main", + approvalKind: "plugin", + request: { + id: "abc", + request: { + title: "Plugin approval", + description: "Let plugin proceed", + sessionKey: "agent:main:discord:dm:123456789", + turnSourceChannel: "discord", + turnSourceTo: "123456789", + turnSourceAccountId: "main", + }, + createdAtMs: 1, + expiresAtMs: 2, + }, + }); + + expect(target).toBeNull(); + }); + + it("accepts raw turn-source ids when a Discord channel session backs them", async () => { + const adapter = createDiscordNativeApprovalAdapter(); + + const target = await adapter.native?.resolveOriginTarget?.({ + cfg: {} as never, + accountId: "main", + approvalKind: "plugin", + request: { + id: "abc", + request: { + title: "Plugin approval", + description: "Let plugin proceed", + sessionKey: "agent:main:discord:channel:123456789", + turnSourceChannel: "discord", + turnSourceTo: "123456789", + turnSourceAccountId: "main", + }, + createdAtMs: 1, + expiresAtMs: 2, + }, + }); + + expect(target).toEqual({ to: "123456789" }); + }); + + it("falls back to extracting the channel id from the session key", async () => { + const adapter = createDiscordNativeApprovalAdapter(); + + const target = await adapter.native?.resolveOriginTarget?.({ + cfg: {} as never, + accountId: "main", + approvalKind: "plugin", + request: { + id: "abc", + request: { + title: "Plugin approval", + description: "Let plugin proceed", + sessionKey: "agent:main:discord:channel:987654321", + }, + createdAtMs: 1, + expiresAtMs: 2, + }, + }); + + expect(target).toEqual({ to: "987654321" }); + }); +}); diff --git a/extensions/discord/src/approval-native.ts b/extensions/discord/src/approval-native.ts new file mode 100644 index 00000000000..71e39dcb274 --- /dev/null +++ b/extensions/discord/src/approval-native.ts @@ -0,0 +1,188 @@ +import { createApproverRestrictedNativeApprovalAdapter } from "openclaw/plugin-sdk/approval-runtime"; +import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { + ExecApprovalRequest, + ExecApprovalSessionTarget, + PluginApprovalRequest, +} from "openclaw/plugin-sdk/infra-runtime"; +import { resolveExecApprovalSessionTarget } from "openclaw/plugin-sdk/approval-runtime"; +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js"; +import { + getDiscordExecApprovalApprovers, + isDiscordExecApprovalApprover, + isDiscordExecApprovalClientEnabled, +} from "./exec-approvals.js"; + +type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; + +export function extractDiscordChannelId(sessionKey?: string | null): string | null { + if (!sessionKey) { + return null; + } + const match = sessionKey.match(/discord:(?:channel|group):(\d+)/); + return match ? match[1] : null; +} + +function extractDiscordSessionKind(sessionKey?: string | null): "channel" | "group" | "dm" | null { + if (!sessionKey) { + return null; + } + const match = sessionKey.match(/discord:(channel|group|dm):/); + if (!match) { + return null; + } + return match[1] as "channel" | "group" | "dm"; +} + +function isExecApprovalRequest(request: ApprovalRequest): request is ExecApprovalRequest { + return "command" in request.request; +} + +function toExecLikeRequest(request: ApprovalRequest): ExecApprovalRequest { + if (isExecApprovalRequest(request)) { + return request; + } + return { + id: request.id, + request: { + command: request.request.title, + sessionKey: request.request.sessionKey ?? undefined, + turnSourceChannel: request.request.turnSourceChannel ?? undefined, + turnSourceTo: request.request.turnSourceTo ?? undefined, + turnSourceAccountId: request.request.turnSourceAccountId ?? undefined, + turnSourceThreadId: request.request.turnSourceThreadId ?? undefined, + }, + createdAtMs: request.createdAtMs, + expiresAtMs: request.expiresAtMs, + }; +} + +function normalizeDiscordOriginChannelId(value?: string | null): string | null { + if (!value) { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const prefixed = trimmed.match(/^(?:channel|group):(\d+)$/i); + if (prefixed) { + return prefixed[1]; + } + return /^\d+$/.test(trimmed) ? trimmed : null; +} + +function resolveRequestSessionTarget(params: { + cfg: OpenClawConfig; + request: ApprovalRequest; +}): ExecApprovalSessionTarget | null { + const execLikeRequest = toExecLikeRequest(params.request); + return resolveExecApprovalSessionTarget({ + cfg: params.cfg, + request: execLikeRequest, + turnSourceChannel: execLikeRequest.request.turnSourceChannel ?? undefined, + turnSourceTo: execLikeRequest.request.turnSourceTo ?? undefined, + turnSourceAccountId: execLikeRequest.request.turnSourceAccountId ?? undefined, + turnSourceThreadId: execLikeRequest.request.turnSourceThreadId ?? undefined, + }); +} + +function resolveDiscordOriginTarget(params: { + cfg: OpenClawConfig; + accountId?: string | null; + request: ApprovalRequest; +}) { + const sessionKind = extractDiscordSessionKind(params.request.request.sessionKey?.trim() || null); + const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || ""; + const rawTurnSourceTo = params.request.request.turnSourceTo?.trim() || ""; + const turnSourceTo = normalizeDiscordOriginChannelId(rawTurnSourceTo); + const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || ""; + const hasExplicitOriginTarget = /^(?:channel|group):/i.test(rawTurnSourceTo); + const turnSourceTarget = + turnSourceChannel === "discord" && + turnSourceTo && + sessionKind !== "dm" && + (hasExplicitOriginTarget || sessionKind === "channel" || sessionKind === "group") + ? { + to: turnSourceTo, + accountId: turnSourceAccountId || undefined, + } + : null; + if ( + turnSourceTarget?.accountId && + params.accountId && + normalizeAccountId(turnSourceTarget.accountId) !== normalizeAccountId(params.accountId) + ) { + return null; + } + + const sessionTarget = resolveRequestSessionTarget(params); + if ( + sessionTarget?.channel === "discord" && + sessionTarget.accountId && + params.accountId && + normalizeAccountId(sessionTarget.accountId) !== normalizeAccountId(params.accountId) + ) { + return null; + } + if ( + turnSourceTarget && + sessionTarget?.channel === "discord" && + turnSourceTarget.to !== normalizeDiscordOriginChannelId(sessionTarget.to) + ) { + return null; + } + + if (turnSourceTarget) { + return { to: turnSourceTarget.to }; + } + if (sessionTarget?.channel === "discord") { + const targetTo = normalizeDiscordOriginChannelId(sessionTarget.to); + return targetTo ? { to: targetTo } : null; + } + const legacyChannelId = extractDiscordChannelId(params.request.request.sessionKey?.trim() || null); + if (legacyChannelId) { + return { to: legacyChannelId }; + } + return null; +} + +function resolveDiscordApproverDmTargets(params: { + cfg: OpenClawConfig; + accountId?: string | null; + configOverride?: DiscordExecApprovalConfig | null; +}) { + return getDiscordExecApprovalApprovers({ + cfg: params.cfg, + accountId: params.accountId, + configOverride: params.configOverride, + }).map((approver) => ({ to: String(approver) })); +} + +export function createDiscordNativeApprovalAdapter( + configOverride?: DiscordExecApprovalConfig | null, +) { + return createApproverRestrictedNativeApprovalAdapter({ + channel: "discord", + channelLabel: "Discord", + listAccountIds: listDiscordAccountIds, + hasApprovers: ({ cfg, accountId }) => + getDiscordExecApprovalApprovers({ cfg, accountId, configOverride }).length > 0, + isExecAuthorizedSender: ({ cfg, accountId, senderId }) => + isDiscordExecApprovalApprover({ cfg, accountId, senderId, configOverride }), + isNativeDeliveryEnabled: ({ cfg, accountId }) => + isDiscordExecApprovalClientEnabled({ cfg, accountId, configOverride }), + resolveNativeDeliveryMode: ({ cfg, accountId }) => + configOverride?.target ?? + resolveDiscordAccount({ cfg, accountId }).config.execApprovals?.target ?? + "dm", + resolveOriginTarget: ({ cfg, accountId, request }) => + resolveDiscordOriginTarget({ cfg, accountId, request }), + resolveApproverDmTargets: ({ cfg, accountId }) => + resolveDiscordApproverDmTargets({ cfg, accountId, configOverride }), + notifyOriginWhenDmOnly: true, + }); +} + +export const discordNativeApprovalAdapter = createDiscordNativeApprovalAdapter(); diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 8bc855281ab..0eb48bd95cd 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -4,7 +4,6 @@ import { createAccountScopedAllowlistNameResolver, createNestedAllowlistOverrideResolver, } from "openclaw/plugin-sdk/allowlist-config-edit"; -import { createApproverRestrictedNativeApprovalAdapter } from "openclaw/plugin-sdk/approval-runtime"; import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing"; import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; @@ -28,17 +27,13 @@ import { resolveDiscordAccount, type ResolvedDiscordAccount, } from "./accounts.js"; +import { discordNativeApprovalAdapter } from "./approval-native.js"; import { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./audit.js"; import { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, } from "./directory-config.js"; -import { - getDiscordExecApprovalApprovers, - isDiscordExecApprovalApprover, - isDiscordExecApprovalClientEnabled, - shouldSuppressLocalDiscordExecApprovalPrompt, -} from "./exec-approvals.js"; +import { shouldSuppressLocalDiscordExecApprovalPrompt } from "./exec-approvals.js"; import { resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, @@ -151,20 +146,6 @@ function buildDiscordCrossContextComponents(params: { return [new DiscordUiContainer({ cfg: params.cfg, accountId: params.accountId, components })]; } -const discordNativeApprovalAdapter = createApproverRestrictedNativeApprovalAdapter({ - channel: "discord", - channelLabel: "Discord", - listAccountIds: listDiscordAccountIds, - hasApprovers: ({ cfg, accountId }) => - getDiscordExecApprovalApprovers({ cfg, accountId }).length > 0, - isExecAuthorizedSender: ({ cfg, accountId, senderId }) => - isDiscordExecApprovalApprover({ cfg, accountId, senderId }), - isNativeDeliveryEnabled: ({ cfg, accountId }) => - isDiscordExecApprovalClientEnabled({ cfg, accountId }), - resolveNativeDeliveryMode: ({ cfg, accountId }) => - resolveDiscordAccount({ cfg, accountId }).config.execApprovals?.target ?? "dm", -}); - const resolveDiscordAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({ resolveRecord: (account: ResolvedDiscordAccount) => account.config.guilds, outerLabel: (guildKey) => `guild ${guildKey}`, @@ -347,6 +328,7 @@ export const discordPlugin: ChannelPlugin auth: discordNativeApprovalAdapter.auth, approvals: { delivery: discordNativeApprovalAdapter.delivery, + native: discordNativeApprovalAdapter.native, }, directory: createChannelDirectoryAdapter({ listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params), diff --git a/extensions/discord/src/exec-approvals.ts b/extensions/discord/src/exec-approvals.ts index 2ebbe87a7c7..09d43d4404c 100644 --- a/extensions/discord/src/exec-approvals.ts +++ b/extensions/discord/src/exec-approvals.ts @@ -1,6 +1,7 @@ import { getExecApprovalReplyMetadata } from "openclaw/plugin-sdk/approval-runtime"; import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { resolveDiscordAccount } from "./accounts.js"; import { parseDiscordTarget } from "./targets.js"; @@ -24,10 +25,11 @@ function normalizeDiscordApproverId(value: string): string | undefined { export function getDiscordExecApprovalApprovers(params: { cfg: OpenClawConfig; accountId?: string | null; + configOverride?: DiscordExecApprovalConfig | null; }): string[] { const account = resolveDiscordAccount(params).config; return resolveApprovalApprovers({ - explicit: account.execApprovals?.approvers, + explicit: params.configOverride?.approvers ?? account.execApprovals?.approvers, allowFrom: account.allowFrom, extraAllowFrom: account.dm?.allowFrom, defaultTo: account.defaultTo, @@ -46,21 +48,34 @@ export function getDiscordExecApprovalApprovers(params: { export function isDiscordExecApprovalClientEnabled(params: { cfg: OpenClawConfig; accountId?: string | null; + configOverride?: DiscordExecApprovalConfig | null; }): boolean { - const config = resolveDiscordAccount(params).config.execApprovals; - return Boolean(config?.enabled && getDiscordExecApprovalApprovers(params).length > 0); + const config = params.configOverride ?? resolveDiscordAccount(params).config.execApprovals; + return Boolean( + config?.enabled && + getDiscordExecApprovalApprovers({ + cfg: params.cfg, + accountId: params.accountId, + configOverride: params.configOverride, + }).length > 0, + ); } export function isDiscordExecApprovalApprover(params: { cfg: OpenClawConfig; accountId?: string | null; senderId?: string | null; + configOverride?: DiscordExecApprovalConfig | null; }): boolean { const senderId = params.senderId?.trim(); if (!senderId) { return false; } - return getDiscordExecApprovalApprovers(params).includes(senderId); + return getDiscordExecApprovalApprovers({ + cfg: params.cfg, + accountId: params.accountId, + configOverride: params.configOverride, + }).includes(senderId); } export function shouldSuppressLocalDiscordExecApprovalPrompt(params: { diff --git a/extensions/discord/src/monitor/exec-approvals.test.ts b/extensions/discord/src/monitor/exec-approvals.test.ts index d960dd91ea2..ed5c994ea5e 100644 --- a/extensions/discord/src/monitor/exec-approvals.test.ts +++ b/extensions/discord/src/monitor/exec-approvals.test.ts @@ -3,9 +3,9 @@ import os from "node:os"; import path from "node:path"; import type { ButtonInteraction, ComponentData } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; -import { clearSessionStoreCacheForTest } from "openclaw/plugin-sdk/config-runtime"; -import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions.js"; +import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js"; const STORE_PATH = path.join(os.tmpdir(), "openclaw-exec-approvals-test.json"); @@ -46,9 +46,7 @@ const mockRestPatch = vi.hoisted(() => vi.fn()); const mockRestDelete = vi.hoisted(() => vi.fn()); const gatewayClientStarts = vi.hoisted(() => vi.fn()); const gatewayClientStops = vi.hoisted(() => vi.fn()); -const gatewayClientRequests = vi.hoisted(() => - vi.fn(async (_method?: string, _params?: unknown) => ({ ok: true })), -); +const gatewayClientRequests = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => ({ ok: true }))); const gatewayClientParams = vi.hoisted(() => [] as Array>); const mockGatewayClientCtor = vi.hoisted(() => vi.fn()); const mockResolveGatewayConnectionAuth = vi.hoisted(() => vi.fn()); @@ -87,8 +85,8 @@ vi.mock("openclaw/plugin-sdk/gateway-runtime", async (importOriginal) => { stop() { gatewayClientStops(); } - async request(method: string, params?: unknown) { - return gatewayClientRequests(method, params); + async request(...args: unknown[]) { + return gatewayClientRequests(...args); } } return { @@ -128,56 +126,9 @@ vi.mock("openclaw/plugin-sdk/gateway-runtime", async (importOriginal) => { }; }); -vi.mock("../../../../src/gateway/operator-approvals-client.js", () => ({ - createOperatorApprovalsGatewayClient: async (params: { - config?: unknown; - gatewayUrl?: string; - clientDisplayName?: string; - onEvent?: unknown; - onHelloOk?: unknown; - onConnectError?: unknown; - onClose?: unknown; - }) => { - mockCreateOperatorApprovalsGatewayClient(params); - const envUrl = process.env.OPENCLAW_GATEWAY_URL?.trim(); - const gatewayUrl = params.gatewayUrl?.trim() || envUrl || "ws://127.0.0.1:18789"; - const urlOverrideSource = params.gatewayUrl?.trim() ? "cli" : envUrl ? "env" : undefined; - const auth = await mockResolveGatewayConnectionAuth({ - config: params.config, - env: process.env, - ...(urlOverrideSource - ? { - urlOverride: gatewayUrl, - urlOverrideSource, - } - : {}), - }); - const clientParams = { - url: gatewayUrl, - token: auth?.token, - password: auth?.password, - clientName: "gateway-client", - clientDisplayName: params.clientDisplayName, - mode: "backend", - scopes: ["operator.approvals"], - onEvent: params.onEvent, - onHelloOk: params.onHelloOk, - onConnectError: params.onConnectError, - onClose: params.onClose, - }; - gatewayClientParams.push(clientParams); - mockGatewayClientCtor(clientParams); - return { - start: gatewayClientStarts, - stop: gatewayClientStops, - request: gatewayClientRequests, - }; - }, -})); - -vi.mock("../../../../src/gateway/client.js", () => ({ - GatewayClient: class { - params: Record; +vi.mock("../../../../src/gateway/operator-approvals-client.js", async () => { + class MockGatewayClient { + private params: Record; constructor(params: Record) { this.params = params; gatewayClientParams.push(params); @@ -189,31 +140,58 @@ vi.mock("../../../../src/gateway/client.js", () => ({ stop() { gatewayClientStops(); } - async request() { - return gatewayClientRequests(); + async request(...args: unknown[]) { + return gatewayClientRequests(...args); } - }, -})); + } -vi.mock("../../../../src/gateway/connection-auth.js", () => ({ - resolveGatewayConnectionAuth: (params: { - config?: unknown; - env: NodeJS.ProcessEnv; - urlOverride?: string; - urlOverrideSource?: "cli" | "env"; - }) => mockResolveGatewayConnectionAuth(params), -})); - -vi.mock("../client.js", () => ({ - createDiscordClient: () => ({ - rest: { - post: mockRestPost, - patch: mockRestPatch, - delete: mockRestDelete, + return { + createOperatorApprovalsGatewayClient: async (params: { + config?: { + gateway?: { + auth?: { + token?: string; + password?: string; + }; + }; + }; + gatewayUrl?: string; + clientDisplayName: string; + onEvent?: unknown; + onHelloOk?: () => void; + onConnectError?: (err: Error) => void; + onClose?: (code: number, reason: string) => void; + }) => { + mockCreateOperatorApprovalsGatewayClient(params); + const envUrl = process.env.OPENCLAW_GATEWAY_URL?.trim(); + const gatewayUrl = params.gatewayUrl?.trim() || envUrl || "ws://127.0.0.1:18789"; + const urlOverrideSource = params.gatewayUrl?.trim() ? "cli" : envUrl ? "env" : undefined; + const auth = await mockResolveGatewayConnectionAuth({ + config: params.config, + env: process.env, + ...(urlOverrideSource + ? { + urlOverride: gatewayUrl, + urlOverrideSource, + } + : {}), + }); + return new MockGatewayClient({ + url: gatewayUrl, + token: auth?.token, + password: auth?.password, + clientName: "gateway-client", + clientDisplayName: params.clientDisplayName, + mode: "backend", + scopes: ["operator.approvals"], + onEvent: params.onEvent, + onHelloOk: params.onHelloOk, + onConnectError: params.onConnectError, + onClose: params.onClose, + }); }, - request: (_fn: () => Promise, _label: string) => _fn(), - }), -})); + }; +}); vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => { const actual = await importOriginal(); @@ -242,66 +220,6 @@ type ExecApprovalRequest = import("./exec-approvals.js").ExecApprovalRequest; type PluginApprovalRequest = import("./exec-approvals.js").PluginApprovalRequest; type ExecApprovalButtonContext = import("./exec-approvals.js").ExecApprovalButtonContext; -function createTestingDeps() { - return { - createGatewayClient: async (params: { - config?: unknown; - gatewayUrl?: string; - clientDisplayName?: string; - onEvent?: unknown; - onHelloOk?: unknown; - onConnectError?: unknown; - onClose?: unknown; - }) => { - mockCreateOperatorApprovalsGatewayClient(params); - const envUrl = process.env.OPENCLAW_GATEWAY_URL?.trim(); - const gatewayUrl = params.gatewayUrl?.trim() || envUrl || "ws://127.0.0.1:18789"; - const urlOverrideSource = params.gatewayUrl?.trim() ? "cli" : envUrl ? "env" : undefined; - const auth = await mockResolveGatewayConnectionAuth({ - config: params.config, - env: process.env, - ...(urlOverrideSource - ? { - urlOverride: gatewayUrl, - urlOverrideSource, - } - : {}), - }); - const clientParams = { - url: gatewayUrl, - token: auth?.token, - password: auth?.password, - clientName: "gateway-client", - clientDisplayName: params.clientDisplayName, - mode: "backend", - scopes: ["operator.approvals"], - onEvent: params.onEvent, - onHelloOk: params.onHelloOk, - onConnectError: params.onConnectError, - onClose: params.onClose, - }; - gatewayClientParams.push(clientParams); - mockGatewayClientCtor(clientParams); - return { - start: gatewayClientStarts, - stop: gatewayClientStops, - request: gatewayClientRequests, - } as unknown as InstanceType< - typeof import("../../../../src/gateway/client.js").GatewayClient - >; - }, - createDiscordClient: () => ({ - rest: { - post: mockRestPost, - patch: mockRestPatch, - delete: mockRestDelete, - }, - request: (_fn: () => Promise, _label: string) => _fn(), - token: "test-token", - }), - }; -} - // ─── Helpers ────────────────────────────────────────────────────────────────── function createHandler(config: DiscordExecApprovalConfig, accountId = "default") { @@ -310,7 +228,6 @@ function createHandler(config: DiscordExecApprovalConfig, accountId = "default") accountId, config, cfg: { session: { store: STORE_PATH } }, - __testing: createTestingDeps(), }); } @@ -370,30 +287,6 @@ async function expectGatewayAuthStart(params: { expect(mockGatewayClientCtor).toHaveBeenCalledWith(expect.objectContaining(expectedClientParams)); } -type ExecApprovalHandlerInternals = { - pending: Map< - string, - { discordMessageId: string; discordChannelId: string; timeoutId: NodeJS.Timeout } - >; - requestCache: Map; - handleApprovalRequested: (request: ExecApprovalRequest | PluginApprovalRequest) => Promise; - handleApprovalTimeout: (approvalId: string, source?: "channel" | "dm") => Promise; -}; - -function getHandlerInternals( - handler: DiscordExecApprovalHandlerInstance, -): ExecApprovalHandlerInternals { - return handler as unknown as ExecApprovalHandlerInternals; -} - -function clearPendingTimeouts(handler: DiscordExecApprovalHandlerInstance) { - const internals = getHandlerInternals(handler); - for (const pending of internals.pending.values()) { - clearTimeout(pending.timeoutId); - } - internals.pending.clear(); -} - function createRequest( overrides: Partial = {}, ): ExecApprovalRequest { @@ -418,11 +311,10 @@ function createPluginRequest( return { id: "plugin:test-id", request: { - title: "Sensitive plugin action", - description: "The plugin wants to run a sensitive tool action.", - severity: "warning", - toolName: "plugin.tool", - pluginId: "plugin-test", + title: "Plugin approval required", + description: "Allow plugin action", + pluginId: "test-plugin", + toolName: "test-tool", agentId: "test-agent", sessionKey: "agent:test-agent:discord:channel:999888777", ...overrides, @@ -432,19 +324,6 @@ function createPluginRequest( }; } -function createMockButtonInteraction(userId: string) { - const reply = vi.fn().mockResolvedValue(undefined); - const acknowledge = vi.fn().mockResolvedValue(undefined); - const followUp = vi.fn().mockResolvedValue(undefined); - const interaction = { - userId, - reply, - acknowledge, - followUp, - } as unknown as ButtonInteraction; - return { interaction, reply, acknowledge, followUp }; -} - beforeEach(() => { mockRestPost.mockReset(); mockRestPatch.mockReset(); @@ -458,6 +337,7 @@ beforeEach(() => { }); beforeAll(async () => { + vi.resetModules(); ({ buildExecApprovalCustomId, extractDiscordChannelId, @@ -726,104 +606,6 @@ describe("DiscordExecApprovalHandler.shouldHandle", () => { }); }); -describe("DiscordExecApprovalHandler plugin approvals", () => { - beforeEach(() => { - mockRestPost.mockClear().mockResolvedValue({ id: "mock-message", channel_id: "mock-channel" }); - mockRestPatch.mockClear().mockResolvedValue({}); - mockRestDelete.mockClear().mockResolvedValue({}); - }); - - it("delivers plugin approval requests with interactive approval buttons", async () => { - const handler = createHandler({ enabled: true, approvers: ["123"] }); - const internals = getHandlerInternals(handler); - mockSuccessfulDmDelivery({ throwOnUnexpectedRoute: true }); - - await internals.handleApprovalRequested(createPluginRequest()); - - const dmCall = mockRestPost.mock.calls.find( - (call) => call[0] === Routes.channelMessages("dm-1"), - ) as [string, { body?: unknown }] | undefined; - expect(dmCall).toBeDefined(); - expect(dmCall?.[1]?.body).toBeDefined(); - const bodyJson = JSON.stringify(dmCall?.[1]?.body ?? {}); - expect(bodyJson).toContain("Plugin Approval Required"); - expect(bodyJson).toContain("plugin:test-id"); - expect(bodyJson).toContain("execapproval:id=plugin%3Atest-id;action=allow-once"); - - clearPendingTimeouts(handler); - }); - - it("handles plugin approvals end-to-end via gateway event, button resolve, and card update", async () => { - const handler = createHandler({ enabled: true, approvers: ["123"] }); - mockSuccessfulDmDelivery({ - noteChannelId: "999888777", - expectedNoteText: "I sent approval DMs to the approvers for this account", - throwOnUnexpectedRoute: true, - }); - - await handler.start(); - try { - const onEvent = gatewayClientParams[0]?.onEvent as - | ((evt: { event: string; payload: unknown }) => void) - | undefined; - expect(typeof onEvent).toBe("function"); - - const request = createPluginRequest(); - onEvent?.({ - event: "plugin.approval.requested", - payload: request, - }); - - await vi.waitFor(() => { - expect(mockRestPost).toHaveBeenCalledWith( - Routes.channelMessages("dm-1"), - expect.objectContaining({ - body: expect.objectContaining({ - components: expect.any(Array), - }), - }), - ); - }); - - const button = new ExecApprovalButton({ handler }); - const { interaction, acknowledge } = createMockButtonInteraction("123"); - await button.run(interaction, { id: request.id, action: "allow-once" }); - - expect(acknowledge).toHaveBeenCalledTimes(1); - expect(gatewayClientRequests).toHaveBeenCalledWith("plugin.approval.resolve", { - id: request.id, - decision: "allow-once", - }); - - onEvent?.({ - event: "plugin.approval.resolved", - payload: { - id: request.id, - decision: "allow-once", - resolvedBy: "discord:123", - ts: Date.now(), - request: request.request, - }, - }); - - await vi.waitFor(() => { - expect(mockRestPatch).toHaveBeenCalledWith( - Routes.channelMessage("dm-1", "msg-1"), - expect.objectContaining({ body: expect.any(Object) }), - ); - }); - const patchCall = mockRestPatch.mock.calls.find( - (call) => call[0] === Routes.channelMessage("dm-1", "msg-1"), - ) as [string, { body?: unknown }] | undefined; - const patchBody = JSON.stringify(patchCall?.[1]?.body ?? {}); - expect(patchBody).toContain("Plugin Approval: Allowed (once)"); - } finally { - clearPendingTimeouts(handler); - await handler.stop(); - } - }); -}); - // ─── DiscordExecApprovalHandler.getApprovers ────────────────────────────────── describe("DiscordExecApprovalHandler.getApprovers", () => { @@ -853,40 +635,6 @@ describe("DiscordExecApprovalHandler.getApprovers", () => { }); }); -describe("DiscordExecApprovalHandler.resolveApproval", () => { - it("routes non-prefixed approval IDs to exec.approval.resolve", async () => { - const handler = createHandler({ enabled: true, approvers: ["123"] }); - await handler.start(); - - try { - const ok = await handler.resolveApproval("exec-123", "allow-once"); - expect(ok).toBe(true); - expect(gatewayClientRequests).toHaveBeenCalledWith("exec.approval.resolve", { - id: "exec-123", - decision: "allow-once", - }); - } finally { - await handler.stop(); - } - }); - - it("routes plugin-prefixed approval IDs to plugin.approval.resolve", async () => { - const handler = createHandler({ enabled: true, approvers: ["123"] }); - await handler.start(); - - try { - const ok = await handler.resolveApproval("plugin:abc-123", "deny"); - expect(ok).toBe(true); - expect(gatewayClientRequests).toHaveBeenCalledWith("plugin.approval.resolve", { - id: "plugin:abc-123", - decision: "deny", - }); - } finally { - await handler.stop(); - } - }); -}); - // ─── ExecApprovalButton authorization ───────────────────────────────────────── describe("ExecApprovalButton", () => { @@ -924,7 +672,7 @@ describe("ExecApprovalButton", () => { await button.run(interaction, data); expect(reply).toHaveBeenCalledWith({ - content: "⛔ You are not authorized to approve requests.", + content: "⛔ You are not authorized to approve exec requests.", ephemeral: true, }); expect(acknowledge).not.toHaveBeenCalled(); @@ -1101,7 +849,6 @@ describe("DiscordExecApprovalHandler gateway auth", () => { auth: { mode: "token", token: "shared-gateway-token" }, }, }, - __testing: createTestingDeps(), }); await handler.start(); @@ -1128,7 +875,6 @@ describe("DiscordExecApprovalHandler gateway auth", () => { auth: { mode: "token" }, }, }, - __testing: createTestingDeps(), }); try { @@ -1153,40 +899,6 @@ describe("DiscordExecApprovalHandler timeout cleanup", () => { mockRestPatch.mockClear().mockResolvedValue({}); mockRestDelete.mockClear().mockResolvedValue({}); }); - - it("cleans up request cache for the exact approval id", async () => { - const handler = createHandler({ enabled: true, approvers: ["123"] }); - const internals = getHandlerInternals(handler); - const requestA = { ...createRequest(), id: "abc" }; - const requestB = { ...createRequest(), id: "abc2" }; - - internals.requestCache.set("abc", { kind: "exec", request: requestA }); - internals.requestCache.set("abc2", { kind: "exec", request: requestB }); - - const timeoutIdA = setTimeout(() => {}, 0); - const timeoutIdB = setTimeout(() => {}, 0); - clearTimeout(timeoutIdA); - clearTimeout(timeoutIdB); - - internals.pending.set("abc:dm", { - discordMessageId: "m1", - discordChannelId: "c1", - timeoutId: timeoutIdA, - }); - internals.pending.set("abc2:dm", { - discordMessageId: "m2", - discordChannelId: "c2", - timeoutId: timeoutIdB, - }); - - await internals.handleApprovalTimeout("abc", "dm"); - - expect(internals.pending.has("abc:dm")).toBe(false); - expect(internals.requestCache.has("abc")).toBe(false); - expect(internals.requestCache.has("abc2")).toBe(true); - - clearPendingTimeouts(handler); - }); }); // ─── Delivery routing ──────────────────────────────────────────────────────── @@ -1204,12 +916,11 @@ describe("DiscordExecApprovalHandler delivery routing", () => { approvers: ["123"], target: "channel", }); - const internals = getHandlerInternals(handler); mockSuccessfulDmDelivery(); const request = createRequest({ sessionKey: "agent:main:discord:dm:123" }); - await internals.handleApprovalRequested(request); + await handler.handleApprovalRequested(request); expect(mockRestPost).toHaveBeenCalledTimes(2); expect(mockRestPost).toHaveBeenCalledWith(Routes.userChannels(), { @@ -1223,8 +934,6 @@ describe("DiscordExecApprovalHandler delivery routing", () => { }), }), ); - - clearPendingTimeouts(handler); }); it("posts an in-channel note when target is dm and the request came from a non-DM discord conversation", async () => { @@ -1233,7 +942,6 @@ describe("DiscordExecApprovalHandler delivery routing", () => { approvers: ["123"], target: "dm", }); - const internals = getHandlerInternals(handler); mockSuccessfulDmDelivery({ noteChannelId: "999888777", @@ -1241,13 +949,15 @@ describe("DiscordExecApprovalHandler delivery routing", () => { throwOnUnexpectedRoute: true, }); - await internals.handleApprovalRequested(createRequest()); + await handler.handleApprovalRequested(createRequest()); expect(mockRestPost).toHaveBeenCalledWith( Routes.channelMessages("999888777"), expect.objectContaining({ body: expect.objectContaining({ - content: expect.stringContaining("I sent approval DMs to the approvers for this account"), + content: expect.stringContaining( + "I sent approval DMs to the approvers for this account", + ), }), }), ); @@ -1257,8 +967,6 @@ describe("DiscordExecApprovalHandler delivery routing", () => { body: expect.any(Object), }), ); - - clearPendingTimeouts(handler); }); it("does not post an in-channel note when the request already came from a discord DM", async () => { @@ -1267,11 +975,10 @@ describe("DiscordExecApprovalHandler delivery routing", () => { approvers: ["123"], target: "dm", }); - const internals = getHandlerInternals(handler); mockSuccessfulDmDelivery({ throwOnUnexpectedRoute: true }); - await internals.handleApprovalRequested( + await handler.handleApprovalRequested( createRequest({ sessionKey: "agent:main:discord:dm:123" }), ); @@ -1279,8 +986,55 @@ describe("DiscordExecApprovalHandler delivery routing", () => { Routes.channelMessages("999888777"), expect.anything(), ); + }); - clearPendingTimeouts(handler); + it("delivers plugin approvals through the shared runtime flow", async () => { + const handler = createHandler({ + enabled: true, + approvers: ["123"], + target: "dm", + }); + + mockSuccessfulDmDelivery({ throwOnUnexpectedRoute: true }); + + await handler.handleApprovalRequested(createPluginRequest()); + + expect(mockRestPost).toHaveBeenCalledWith( + Routes.channelMessages("dm-1"), + expect.objectContaining({ + body: expect.objectContaining({ + components: expect.arrayContaining([ + expect.objectContaining({ + components: expect.arrayContaining([ + expect.objectContaining({ + content: expect.stringContaining("Plugin Approval Required"), + }), + expect.objectContaining({ + content: expect.stringContaining("Plugin approval required"), + }), + ]), + }), + ]), + }), + }), + ); + }); +}); + +describe("DiscordExecApprovalHandler resolve routing", () => { + it("routes plugin approval ids through plugin.approval.resolve", async () => { + const handler = createHandler({ + enabled: true, + approvers: ["123"], + }); + + await handler.start(); + await expect(handler.resolveApproval("plugin:test-id", "allow-once")).resolves.toBe(true); + + expect(gatewayClientRequests).toHaveBeenCalledWith("plugin.approval.resolve", { + id: "plugin:test-id", + decision: "allow-once", + }); }); }); @@ -1296,7 +1050,6 @@ describe("DiscordExecApprovalHandler gateway auth resolution", () => { gatewayUrl: "wss://override.example/ws", config: { enabled: true, approvers: ["123"] }, cfg: { session: { store: STORE_PATH } }, - __testing: createTestingDeps(), }); await expectGatewayAuthStart({ @@ -1319,7 +1072,6 @@ describe("DiscordExecApprovalHandler gateway auth resolution", () => { accountId: "default", config: { enabled: true, approvers: ["123"] }, cfg: { session: { store: STORE_PATH } }, - __testing: createTestingDeps(), }); await expectGatewayAuthStart({ diff --git a/extensions/discord/src/monitor/exec-approvals.ts b/extensions/discord/src/monitor/exec-approvals.ts index 9afa80a2ad0..03fefb27dba 100644 --- a/extensions/discord/src/monitor/exec-approvals.ts +++ b/extensions/discord/src/monitor/exec-approvals.ts @@ -10,20 +10,25 @@ import { type TopLevelComponents, } from "@buape/carbon"; import { ButtonStyle, Routes } from "discord-api-types/v10"; -import { - getExecApprovalApproverDmNoticeText, - resolveExecApprovalCommandDisplay, - type ExecApprovalDecision, - type ExecApprovalRequest, - type ExecApprovalResolved, - type PluginApprovalRequest, - type PluginApprovalResolved, -} from "openclaw/plugin-sdk/approval-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime"; -import type { EventFrame } from "openclaw/plugin-sdk/gateway-runtime"; -import * as gatewayRuntime from "openclaw/plugin-sdk/gateway-runtime"; +import { + createExecApprovalChannelRuntime, + type ExecApprovalChannelRuntime, + resolveChannelNativeApprovalDeliveryPlan, +} from "openclaw/plugin-sdk/infra-runtime"; +import { buildExecApprovalActionDescriptors } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime"; +import { getExecApprovalApproverDmNoticeText } from "openclaw/plugin-sdk/infra-runtime"; +import type { + ExecApprovalActionDescriptor, + ExecApprovalDecision, + ExecApprovalRequest, + ExecApprovalResolved, + PluginApprovalRequest, + PluginApprovalResolved, +} from "openclaw/plugin-sdk/infra-runtime"; import { normalizeAccountId, normalizeMessageChannel, @@ -32,10 +37,12 @@ import { import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { compileSafeRegex, testRegexWithBoundedInput } from "openclaw/plugin-sdk/security-runtime"; import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime"; -import * as sendShared from "../send.shared.js"; +import { createDiscordNativeApprovalAdapter } from "../approval-native.js"; +import { createDiscordClient, stripUndefinedFields } from "../send.shared.js"; import { DiscordUiContainer } from "../ui.js"; const EXEC_APPROVAL_KEY = "execapproval"; +export { extractDiscordChannelId } from "../approval-native.js"; export type { ExecApprovalRequest, ExecApprovalResolved, @@ -43,15 +50,9 @@ export type { PluginApprovalResolved, }; -/** Extract Discord channel ID from a session key like "agent:main:discord:channel:123456789" */ -export function extractDiscordChannelId(sessionKey?: string | null): string | null { - if (!sessionKey) { - return null; - } - // Session key format: agent::discord:channel: or agent::discord:group: - const match = sessionKey.match(/discord:(?:channel|group):(\d+)/); - return match ? match[1] : null; -} +type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; +type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved; +type ApprovalKind = "exec" | "plugin"; function buildDiscordApprovalDmRedirectNotice(): { content: string } { return { @@ -62,14 +63,16 @@ function buildDiscordApprovalDmRedirectNotice(): { content: string } { type PendingApproval = { discordMessageId: string; discordChannelId: string; - timeoutId: NodeJS.Timeout; + timeoutId?: NodeJS.Timeout; }; -type ApprovalKind = "exec" | "plugin"; +function resolveApprovalKindFromId(approvalId: string): ApprovalKind { + return approvalId.startsWith("plugin:") ? "plugin" : "exec"; +} -type CachedApprovalRequest = - | { kind: "exec"; request: ExecApprovalRequest } - | { kind: "plugin"; request: PluginApprovalRequest }; +function isPluginApprovalRequest(request: ApprovalRequest): request is PluginApprovalRequest { + return resolveApprovalKindFromId(request.id) === "plugin"; +} function encodeCustomIdValue(value: string): string { return encodeURIComponent(value); @@ -115,16 +118,6 @@ export function parseExecApprovalData( }; } -function resolveApprovalKindFromId(approvalId: string): ApprovalKind { - return approvalId.startsWith("plugin:") ? "plugin" : "exec"; -} - -function isPluginApprovalRequest( - request: ExecApprovalRequest | PluginApprovalRequest, -): request is PluginApprovalRequest { - return resolveApprovalKindFromId(request.id) === "plugin"; -} - type ExecApprovalContainerParams = { cfg: OpenClawConfig; accountId: string; @@ -177,49 +170,36 @@ class ExecApprovalActionButton extends Button { label: string; style: ButtonStyle; - constructor(params: { - approvalId: string; - action: ExecApprovalDecision; - label: string; - style: ButtonStyle; - }) { + constructor(params: { approvalId: string; descriptor: ExecApprovalActionDescriptor }) { super(); - this.customId = buildExecApprovalCustomId(params.approvalId, params.action); - this.label = params.label; - this.style = params.style; + this.customId = buildExecApprovalCustomId(params.approvalId, params.descriptor.decision); + this.label = params.descriptor.label; + this.style = + params.descriptor.style === "success" + ? ButtonStyle.Success + : params.descriptor.style === "primary" + ? ButtonStyle.Primary + : params.descriptor.style === "danger" + ? ButtonStyle.Danger + : ButtonStyle.Secondary; } } class ExecApprovalActionRow extends Row