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
This commit is contained in:
scoootscooob 2026-03-30 15:49:02 -07:00 committed by GitHub
parent e7e15b92bd
commit 9ff57ac479
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 3606 additions and 2136 deletions

View File

@ -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

View File

@ -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"
}
},

View File

@ -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<ResolvedAccount>;","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<HeartbeatEventPayload, \"ts\">): 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<void>) | undefined; }): Promise<void>;","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<TState>(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<ResolvedAccount>;","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<ResolvedAccount>;","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<ResolvedAccount>;","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<ResolvedAccount>;","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<LookupAddress>; (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<string, unknown>, 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<boolean> | null; ackReactionValue: string | null; remove: () => Promise<void>; 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<void>;","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"}

View File

@ -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" });
});
});

View File

@ -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();

View File

@ -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<ResolvedDiscordAccount, DiscordProbe>
auth: discordNativeApprovalAdapter.auth,
approvals: {
delivery: discordNativeApprovalAdapter.delivery,
native: discordNativeApprovalAdapter.native,
},
directory: createChannelDirectoryAdapter({
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),

View File

@ -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: {

View File

@ -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<Record<string, unknown>>);
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<string, unknown>;
vi.mock("../../../../src/gateway/operator-approvals-client.js", async () => {
class MockGatewayClient {
private params: Record<string, unknown>;
constructor(params: Record<string, unknown>) {
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<unknown>, _label: string) => _fn(),
}),
}));
};
});
vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/text-runtime")>();
@ -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<unknown>, _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<string, unknown>;
handleApprovalRequested: (request: ExecApprovalRequest | PluginApprovalRequest) => Promise<void>;
handleApprovalTimeout: (approvalId: string, source?: "channel" | "dm") => Promise<void>;
};
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["request"]> = {},
): 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({

View File

@ -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:<id>:discord:channel:<channelId> or agent:<id>:discord:group:<channelId>
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<Button> {
constructor(approvalId: string) {
super([
new ExecApprovalActionButton({
approvalId,
action: "allow-once",
label: "Allow once",
style: ButtonStyle.Success,
}),
new ExecApprovalActionButton({
approvalId,
action: "allow-always",
label: "Always allow",
style: ButtonStyle.Primary,
}),
new ExecApprovalActionButton({
approvalId,
action: "deny",
label: "Deny",
style: ButtonStyle.Danger,
}),
...buildExecApprovalActionDescriptors({ approvalCommandId: approvalId }).map(
(descriptor) => new ExecApprovalActionButton({ approvalId, descriptor }),
),
]);
}
}
function resolveAccountIdFromSessionKey(params: {
function resolveExecApprovalAccountId(params: {
cfg: OpenClawConfig;
sessionKey?: string | null;
request: ExecApprovalRequest;
}): string | null {
const sessionKey = params.sessionKey?.trim();
const sessionKey = params.request.request.sessionKey?.trim();
if (!sessionKey) {
return null;
}
@ -239,23 +219,21 @@ function resolveAccountIdFromSessionKey(params: {
}
}
function resolveExecApprovalAccountId(params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest;
}): string | null {
return resolveAccountIdFromSessionKey({
cfg: params.cfg,
sessionKey: params.request.request.sessionKey,
});
}
function resolvePluginApprovalAccountId(params: {
cfg: OpenClawConfig;
request: PluginApprovalRequest;
}): string | null {
const fromSession = resolveAccountIdFromSessionKey({
const fromSession = resolveExecApprovalAccountId({
cfg: params.cfg,
sessionKey: params.request.request.sessionKey,
request: {
id: params.request.id,
request: {
command: params.request.request.title,
sessionKey: params.request.request.sessionKey ?? undefined,
},
createdAtMs: params.request.createdAtMs,
expiresAtMs: params.request.expiresAtMs,
},
});
if (fromSession) {
return fromSession;
@ -265,22 +243,18 @@ function resolvePluginApprovalAccountId(params: {
function resolveApprovalAccountId(params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest | PluginApprovalRequest;
request: ApprovalRequest;
}): string | null {
return isPluginApprovalRequest(params.request)
? resolvePluginApprovalAccountId({ cfg: params.cfg, request: params.request })
: resolveExecApprovalAccountId({ cfg: params.cfg, request: params.request });
}
function resolveApprovalAgentId(
request: ExecApprovalRequest | PluginApprovalRequest,
): string | null {
function resolveApprovalAgentId(request: ApprovalRequest): string | null {
return request.request.agentId?.trim() || null;
}
function resolveApprovalSessionKey(
request: ExecApprovalRequest | PluginApprovalRequest,
): string | null {
function resolveApprovalSessionKey(request: ApprovalRequest): string | null {
return request.request.sessionKey?.trim() || null;
}
@ -526,31 +500,38 @@ export type DiscordExecApprovalHandlerOpts = {
cfg: OpenClawConfig;
runtime?: RuntimeEnv;
onResolve?: (id: string, decision: ExecApprovalDecision) => Promise<void>;
__testing?: {
createGatewayClient?: typeof gatewayRuntime.createOperatorApprovalsGatewayClient;
createDiscordClient?: (...args: Parameters<typeof sendShared.createDiscordClient>) => {
rest: {
post: (...args: unknown[]) => Promise<unknown>;
patch: (...args: unknown[]) => Promise<unknown>;
delete: (...args: unknown[]) => Promise<unknown>;
};
request: (fn: () => Promise<unknown>, label: string) => Promise<unknown>;
};
};
};
export class DiscordExecApprovalHandler {
private gatewayClient: gatewayRuntime.GatewayClient | null = null;
private pending = new Map<string, PendingApproval>();
private requestCache = new Map<string, CachedApprovalRequest>();
private readonly runtime: ExecApprovalChannelRuntime<ApprovalRequest, ApprovalResolved>;
private opts: DiscordExecApprovalHandlerOpts;
private started = false;
constructor(opts: DiscordExecApprovalHandlerOpts) {
this.opts = opts;
this.runtime = createExecApprovalChannelRuntime<
PendingApproval,
ApprovalRequest,
ApprovalResolved
>({
label: "discord/exec-approvals",
clientDisplayName: "Discord Exec Approvals",
cfg: this.opts.cfg,
gatewayUrl: this.opts.gatewayUrl,
eventKinds: ["exec", "plugin"],
isConfigured: () =>
Boolean(this.opts.config.enabled && (this.opts.config.approvers?.length ?? 0) > 0),
shouldHandle: (request) => this.shouldHandle(request),
deliverRequested: async (request) => await this.deliverRequested(request),
finalizeResolved: async ({ request, resolved, entries }) => {
await this.finalizeResolved(request, resolved, entries);
},
finalizeExpired: async ({ request, entries }) => {
await this.finalizeExpired(request, entries);
},
});
}
shouldHandle(request: ExecApprovalRequest | PluginApprovalRequest): boolean {
shouldHandle(request: ApprovalRequest): boolean {
const config = this.opts.config;
if (!config.enabled) {
return false;
@ -603,133 +584,51 @@ export class DiscordExecApprovalHandler {
}
async start(): Promise<void> {
if (this.started) {
return;
}
this.started = true;
const config = this.opts.config;
if (!config.enabled) {
logDebug("discord exec approvals: disabled");
return;
}
if (!config.approvers || config.approvers.length === 0) {
logDebug("discord exec approvals: no approvers configured");
return;
}
logDebug("discord exec approvals: starting handler");
this.gatewayClient = await (
this.opts.__testing?.createGatewayClient ??
gatewayRuntime.createOperatorApprovalsGatewayClient
)({
config: this.opts.cfg,
gatewayUrl: this.opts.gatewayUrl,
clientDisplayName: "Discord Exec Approvals",
onEvent: (evt) => this.handleGatewayEvent(evt),
onHelloOk: () => {
logDebug("discord exec approvals: connected to gateway");
},
onConnectError: (err) => {
logError(`discord exec approvals: connect error: ${err.message}`);
},
onClose: (code, reason) => {
logDebug(`discord exec approvals: gateway closed: ${code} ${reason}`);
},
});
this.gatewayClient.start();
await this.runtime.start();
}
async stop(): Promise<void> {
if (!this.started) {
return;
}
this.started = false;
// Clear all pending timeouts
for (const pending of this.pending.values()) {
clearTimeout(pending.timeoutId);
}
this.pending.clear();
this.requestCache.clear();
this.gatewayClient?.stop();
this.gatewayClient = null;
logDebug("discord exec approvals: stopped");
await this.runtime.stop();
}
private handleGatewayEvent(evt: EventFrame): void {
if (evt.event === "exec.approval.requested") {
const request = evt.payload as ExecApprovalRequest;
void this.handleApprovalRequested(request);
} else if (evt.event === "plugin.approval.requested") {
const request = evt.payload as PluginApprovalRequest;
void this.handleApprovalRequested(request);
} else if (evt.event === "exec.approval.resolved") {
const resolved = evt.payload as ExecApprovalResolved;
void this.handleApprovalResolved(resolved);
} else if (evt.event === "plugin.approval.resolved") {
const resolved = evt.payload as PluginApprovalResolved;
void this.handleApprovalResolved(resolved);
}
}
private async handleApprovalRequested(
request: ExecApprovalRequest | PluginApprovalRequest,
): Promise<void> {
if (!this.shouldHandle(request)) {
return;
}
const pluginRequest: PluginApprovalRequest | null = isPluginApprovalRequest(request)
? request
: null;
logDebug(
`discord exec approvals: received ${pluginRequest ? "plugin" : "exec"} request ${request.id}`,
private async deliverRequested(request: ApprovalRequest): Promise<PendingApproval[]> {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
let container: ExecApprovalContainer;
if (pluginRequest) {
this.requestCache.set(request.id, { kind: "plugin", request: pluginRequest });
container = createPluginApprovalRequestContainer({
request: pluginRequest,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
actionRow: new ExecApprovalActionRow(request.id),
});
} else {
const execRequest = request as ExecApprovalRequest;
this.requestCache.set(request.id, { kind: "exec", request: execRequest });
container = createExecApprovalRequestContainer({
request: execRequest,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
actionRow: new ExecApprovalActionRow(request.id),
});
}
const { rest, request: discordRequest } = (
this.opts.__testing?.createDiscordClient ?? sendShared.createDiscordClient
)({ token: this.opts.token, accountId: this.opts.accountId }, this.opts.cfg);
const actionRow = new ExecApprovalActionRow(request.id);
const container = isPluginApprovalRequest(request)
? createPluginApprovalRequestContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
actionRow,
})
: createExecApprovalRequestContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
actionRow,
});
const payload = buildExecApprovalPayload(container);
const body = sendShared.stripUndefinedFields(serializePayload(payload));
const target = this.opts.config.target ?? "dm";
const sendToDm = target === "dm" || target === "both";
const sendToChannel = target === "channel" || target === "both";
let fallbackToDm = false;
const sessionKey = resolveApprovalSessionKey(request);
const originatingChannelId =
sessionKey && target === "dm" ? extractDiscordChannelId(sessionKey) : null;
if (target === "dm" && originatingChannelId) {
const body = stripUndefinedFields(serializePayload(payload));
const approvalKind: ApprovalKind = isPluginApprovalRequest(request) ? "plugin" : "exec";
const nativeApprovalAdapter = createDiscordNativeApprovalAdapter(this.opts.config);
const deliveryPlan = await resolveChannelNativeApprovalDeliveryPlan({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
approvalKind,
request,
adapter: nativeApprovalAdapter.native,
});
const pendingEntries: PendingApproval[] = [];
const originTarget = deliveryPlan.originTarget;
if (deliveryPlan.notifyOriginWhenDmOnly && originTarget) {
try {
await discordRequest(
() =>
rest.post(Routes.channelMessages(originatingChannelId), {
rest.post(Routes.channelMessages(originTarget.to), {
body: buildDiscordApprovalDmRedirectNotice(),
}) as Promise<{ id: string; channel_id: string }>,
"send-approval-dm-redirect-notice",
@ -739,199 +638,130 @@ export class DiscordExecApprovalHandler {
}
}
// Send to originating channel if configured
if (sendToChannel) {
const channelId = extractDiscordChannelId(sessionKey);
if (channelId) {
for (const deliveryTarget of deliveryPlan.targets) {
if (deliveryTarget.surface === "origin") {
try {
const message = (await discordRequest(
() =>
rest.post(Routes.channelMessages(channelId), {
rest.post(Routes.channelMessages(deliveryTarget.target.to), {
body,
}) as Promise<{ id: string; channel_id: string }>,
"send-approval-channel",
)) as { id: string; channel_id: string };
if (message?.id) {
const timeoutMs = Math.max(0, request.expiresAtMs - Date.now());
const timeoutId = setTimeout(() => {
void this.handleApprovalTimeout(request.id, "channel");
}, timeoutMs);
this.pending.set(`${request.id}:channel`, {
pendingEntries.push({
discordMessageId: message.id,
discordChannelId: channelId,
timeoutId,
discordChannelId: deliveryTarget.target.to,
});
logDebug(`discord exec approvals: sent approval ${request.id} to channel ${channelId}`);
logDebug(
`discord exec approvals: sent approval ${request.id} to channel ${deliveryTarget.target.to}`,
);
}
} catch (err) {
logError(`discord exec approvals: failed to send to channel: ${String(err)}`);
}
} else {
if (!sendToDm) {
logError(
`discord exec approvals: target is "channel" but could not extract channel id from session key "${sessionKey ?? "(none)"}" — falling back to DM delivery for approval ${request.id}`,
);
fallbackToDm = true;
} else {
logDebug("discord exec approvals: could not extract channel id from session key");
}
}
}
// Send to approver DMs if configured (or as fallback when channel extraction fails)
if (sendToDm || fallbackToDm) {
const approvers = this.opts.config.approvers ?? [];
for (const approver of approvers) {
const userId = String(approver);
try {
// Create DM channel
const dmChannel = (await discordRequest(
() =>
rest.post(Routes.userChannels(), {
body: { recipient_id: userId },
}) as Promise<{ id: string }>,
"dm-channel",
)) as { id: string };
if (!dmChannel?.id) {
logError(`discord exec approvals: failed to create DM for user ${userId}`);
continue;
}
// Send message with components v2 + buttons
const message = (await discordRequest(
() =>
rest.post(Routes.channelMessages(dmChannel.id), {
body,
}) as Promise<{ id: string; channel_id: string }>,
"send-approval",
)) as { id: string; channel_id: string };
if (!message?.id) {
logError(`discord exec approvals: failed to send message to user ${userId}`);
continue;
}
// Clear any existing pending DM entry to avoid timeout leaks
const existingDm = this.pending.get(`${request.id}:dm`);
if (existingDm) {
clearTimeout(existingDm.timeoutId);
}
// Set up timeout
const timeoutMs = Math.max(0, request.expiresAtMs - Date.now());
const timeoutId = setTimeout(() => {
void this.handleApprovalTimeout(request.id, "dm");
}, timeoutMs);
this.pending.set(`${request.id}:dm`, {
discordMessageId: message.id,
discordChannelId: dmChannel.id,
timeoutId,
});
logDebug(`discord exec approvals: sent approval ${request.id} to user ${userId}`);
} catch (err) {
logError(`discord exec approvals: failed to notify user ${userId}: ${String(err)}`);
}
}
}
}
private async handleApprovalResolved(
resolved: ExecApprovalResolved | PluginApprovalResolved,
): Promise<void> {
// Clean up all pending entries for this approval (channel + dm)
const cached = this.requestCache.get(resolved.id);
this.requestCache.delete(resolved.id);
if (!cached) {
return;
}
logDebug(
`discord exec approvals: resolved ${cached.kind} ${resolved.id} with ${resolved.decision}`,
);
const container =
cached.kind === "plugin"
? createPluginResolvedContainer({
request: cached.request,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
})
: createExecResolvedContainer({
request: cached.request,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
for (const suffix of [":channel", ":dm", ""]) {
const key = `${resolved.id}${suffix}`;
const pending = this.pending.get(key);
if (!pending) {
continue;
}
clearTimeout(pending.timeoutId);
this.pending.delete(key);
const userId = deliveryTarget.target.to;
try {
const dmChannel = (await discordRequest(
() =>
rest.post(Routes.userChannels(), {
body: { recipient_id: userId },
}) as Promise<{ id: string }>,
"dm-channel",
)) as { id: string };
if (!dmChannel?.id) {
logError(`discord exec approvals: failed to create DM for user ${userId}`);
continue;
}
const message = (await discordRequest(
() =>
rest.post(Routes.channelMessages(dmChannel.id), {
body,
}) as Promise<{ id: string; channel_id: string }>,
"send-approval",
)) as { id: string; channel_id: string };
if (!message?.id) {
logError(`discord exec approvals: failed to send message to user ${userId}`);
continue;
}
pendingEntries.push({
discordMessageId: message.id,
discordChannelId: dmChannel.id,
});
logDebug(`discord exec approvals: sent approval ${request.id} to user ${userId}`);
} catch (err) {
logError(`discord exec approvals: failed to notify user ${userId}: ${String(err)}`);
}
}
return pendingEntries;
}
async handleApprovalRequested(request: ApprovalRequest): Promise<void> {
await this.runtime.handleRequested(request);
}
async handleApprovalResolved(resolved: ApprovalResolved): Promise<void> {
await this.runtime.handleResolved(resolved);
}
async handleApprovalTimeout(approvalId: string, _source?: "channel" | "dm"): Promise<void> {
await this.runtime.handleExpired(approvalId);
}
private async finalizeResolved(
request: ApprovalRequest,
resolved: ApprovalResolved,
entries: PendingApproval[],
): Promise<void> {
const container = isPluginApprovalRequest(request)
? createPluginResolvedContainer({
request,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
})
: createExecResolvedContainer({
request,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
for (const pending of entries) {
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
}
}
private async handleApprovalTimeout(
approvalId: string,
source?: "channel" | "dm",
private async finalizeExpired(
request: ApprovalRequest,
entries: PendingApproval[],
): Promise<void> {
const key = source ? `${approvalId}:${source}` : approvalId;
const pending = this.pending.get(key);
if (!pending) {
return;
const container = isPluginApprovalRequest(request)
? createPluginExpiredContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
})
: createExecExpiredContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
for (const pending of entries) {
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
}
this.pending.delete(key);
const cached = this.requestCache.get(approvalId);
// Only clean up requestCache if no other pending entries exist for this approval
const hasOtherPending =
this.pending.has(`${approvalId}:channel`) ||
this.pending.has(`${approvalId}:dm`) ||
this.pending.has(approvalId);
if (!hasOtherPending) {
this.requestCache.delete(approvalId);
}
if (!cached) {
return;
}
logDebug(
`discord exec approvals: timeout for ${cached.kind} ${approvalId} (${source ?? "default"})`,
);
const container =
cached.kind === "plugin"
? createPluginExpiredContainer({
request: cached.request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
})
: createExecExpiredContainer({
request: cached.request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
}
private async finalizeMessage(
@ -945,9 +775,10 @@ export class DiscordExecApprovalHandler {
}
try {
const { rest, request: discordRequest } = (
this.opts.__testing?.createDiscordClient ?? sendShared.createDiscordClient
)({ token: this.opts.token, accountId: this.opts.accountId }, this.opts.cfg);
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
await discordRequest(
() => rest.delete(Routes.channelMessage(channelId, messageId)) as Promise<void>,
@ -965,15 +796,16 @@ export class DiscordExecApprovalHandler {
container: DiscordUiContainer,
): Promise<void> {
try {
const { rest, request: discordRequest } = (
this.opts.__testing?.createDiscordClient ?? sendShared.createDiscordClient
)({ token: this.opts.token, accountId: this.opts.accountId }, this.opts.cfg);
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
const payload = buildExecApprovalPayload(container);
await discordRequest(
() =>
rest.patch(Routes.channelMessage(channelId, messageId), {
body: sendShared.stripUndefinedFields(serializePayload(payload)),
body: stripUndefinedFields(serializePayload(payload)),
}),
"update-approval",
);
@ -983,11 +815,6 @@ export class DiscordExecApprovalHandler {
}
async resolveApproval(approvalId: string, decision: ExecApprovalDecision): Promise<boolean> {
if (!this.gatewayClient) {
logError("discord exec approvals: gateway client not connected");
return false;
}
const method =
resolveApprovalKindFromId(approvalId) === "plugin"
? "plugin.approval.resolve"
@ -995,7 +822,7 @@ export class DiscordExecApprovalHandler {
logDebug(`discord exec approvals: resolving ${approvalId} with ${decision} via ${method}`);
try {
await this.gatewayClient.request(method, {
await this.runtime.request(method, {
id: approvalId,
decision,
});
@ -1048,7 +875,7 @@ export class ExecApprovalButton extends Button {
if (!approvers.some((id) => String(id) === userId)) {
try {
await interaction.reply({
content: "⛔ You are not authorized to approve requests.",
content: "⛔ You are not authorized to approve exec requests.",
ephemeral: true,
});
} catch {

View File

@ -0,0 +1,170 @@
import { createApproverRestrictedNativeApprovalAdapter } from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type {
ExecApprovalRequest,
ExecApprovalSessionTarget,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/infra-runtime";
import { resolveExecApprovalSessionTarget } from "openclaw/plugin-sdk/infra-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { listTelegramAccountIds } from "./accounts.js";
import {
getTelegramExecApprovalApprovers,
isTelegramExecApprovalApprover,
isTelegramExecApprovalAuthorizedSender,
isTelegramExecApprovalClientEnabled,
resolveTelegramExecApprovalTarget,
} from "./exec-approvals.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type TelegramOriginTarget = { to: string; threadId?: number; accountId?: string };
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 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 resolveTurnSourceTelegramOriginTarget(params: {
accountId: string;
request: ApprovalRequest;
}): TelegramOriginTarget | null {
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
const turnSourceTo = params.request.request.turnSourceTo?.trim() || "";
const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || "";
if (turnSourceChannel !== "telegram" || !turnSourceTo) {
return null;
}
if (
turnSourceAccountId &&
normalizeAccountId(turnSourceAccountId) !== normalizeAccountId(params.accountId)
) {
return null;
}
const threadId =
typeof params.request.request.turnSourceThreadId === "number"
? params.request.request.turnSourceThreadId
: typeof params.request.request.turnSourceThreadId === "string"
? Number.parseInt(params.request.request.turnSourceThreadId, 10)
: undefined;
return {
to: turnSourceTo,
threadId: Number.isFinite(threadId) ? threadId : undefined,
accountId: turnSourceAccountId || undefined,
};
}
function resolveSessionTelegramOriginTarget(params: {
cfg: OpenClawConfig;
accountId: string;
request: ApprovalRequest;
}): TelegramOriginTarget | null {
const sessionTarget = resolveRequestSessionTarget(params);
if (!sessionTarget || sessionTarget.channel !== "telegram") {
return null;
}
if (
sessionTarget.accountId &&
normalizeAccountId(sessionTarget.accountId) !== normalizeAccountId(params.accountId)
) {
return null;
}
return {
to: sessionTarget.to,
threadId: sessionTarget.threadId,
accountId: sessionTarget.accountId,
};
}
function telegramTargetsMatch(a: TelegramOriginTarget, b: TelegramOriginTarget): boolean {
const accountMatches =
!a.accountId ||
!b.accountId ||
normalizeAccountId(a.accountId) === normalizeAccountId(b.accountId);
return a.to === b.to && a.threadId === b.threadId && accountMatches;
}
function resolveTelegramOriginTarget(params: {
cfg: OpenClawConfig;
accountId: string;
request: ApprovalRequest;
}) {
const turnSourceTarget = resolveTurnSourceTelegramOriginTarget(params);
const sessionTarget = resolveSessionTelegramOriginTarget(params);
if (turnSourceTarget && sessionTarget && !telegramTargetsMatch(turnSourceTarget, sessionTarget)) {
return null;
}
const target = turnSourceTarget ?? sessionTarget;
return target ? { to: target.to, threadId: target.threadId } : null;
}
function resolveTelegramApproverDmTargets(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) {
return getTelegramExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
}).map((approver) => ({ to: approver }));
}
export const telegramNativeApprovalAdapter = createApproverRestrictedNativeApprovalAdapter({
channel: "telegram",
channelLabel: "Telegram",
listAccountIds: listTelegramAccountIds,
hasApprovers: ({ cfg, accountId }) =>
getTelegramExecApprovalApprovers({ cfg, accountId }).length > 0,
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
isTelegramExecApprovalAuthorizedSender({ cfg, accountId, senderId }),
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) =>
isTelegramExecApprovalApprover({ cfg, accountId, senderId }),
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
isTelegramExecApprovalClientEnabled({ cfg, accountId }),
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
resolveTelegramExecApprovalTarget({ cfg, accountId }),
requireMatchingTurnSourceChannel: true,
resolveSuppressionAccountId: ({ target, request }) =>
target.accountId?.trim() || request.request.turnSourceAccountId?.trim() || undefined,
resolveOriginTarget: ({ cfg, accountId, request }) =>
accountId
? resolveTelegramOriginTarget({
cfg,
accountId,
request,
})
: null,
resolveApproverDmTargets: ({ cfg, accountId }) =>
resolveTelegramApproverDmTargets({ cfg, accountId }),
});

View File

@ -11,6 +11,7 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/re
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js";
import { createTelegramDraftStream } from "./draft-stream.js";
import { resolveTelegramExecApproval } from "./exec-approval-resolver.js";
import { editMessageTelegram } from "./send.js";
import { wasSentByBot } from "./sent-message-cache.js";
@ -26,6 +27,7 @@ export type TelegramBotDeps = {
buildModelsProviderData: typeof buildModelsProviderData;
listSkillCommandsForAgents: typeof listSkillCommandsForAgents;
wasSentByBot: typeof wasSentByBot;
resolveExecApproval?: typeof resolveTelegramExecApproval;
createTelegramDraftStream?: typeof createTelegramDraftStream;
deliverReplies?: typeof deliverReplies;
emitInternalMessageSentHook?: typeof emitInternalMessageSentHook;
@ -66,6 +68,9 @@ export const defaultTelegramBotDeps: TelegramBotDeps = {
get wasSentByBot() {
return wasSentByBot;
},
get resolveExecApproval() {
return resolveTelegramExecApproval;
},
get createTelegramDraftStream() {
return createTelegramDraftStream;
},

View File

@ -1,7 +1,7 @@
import type { Message, ReactionTypeEmoji } from "@grammyjs/types";
import { resolveAgentDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime";
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-writes";
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers";
import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-inbound";
import {
createInboundDebouncer,
@ -31,6 +31,7 @@ import {
parsePluginBindingApprovalCustomId,
resolvePluginConversationBindingApproval,
} from "openclaw/plugin-sdk/conversation-runtime";
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/infra-runtime";
import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
@ -43,7 +44,6 @@ import {
} from "./bot-access.js";
import { defaultTelegramBotDeps } from "./bot-deps.js";
import {
APPROVE_CALLBACK_DATA_RE,
hasInboundMedia,
hasReplyTargetMedia,
isMediaSizeLimitError,
@ -70,16 +70,13 @@ import {
resolveTelegramGroupAllowFromContext,
withResolvedTelegramForumFlag,
} from "./bot/helpers.js";
import type {
TelegramContext,
TelegramGetChat,
TelegramSyntheticContextSource,
} from "./bot/types.js";
import type { TelegramContext, TelegramGetChat } from "./bot/types.js";
import {
resolveTelegramConversationBaseSessionKey,
resolveTelegramConversationRoute,
} from "./conversation-route.js";
import { enforceTelegramDmAccess } from "./dm-access.js";
import { resolveTelegramExecApproval } from "./exec-approval-resolver.js";
import {
isTelegramExecApprovalApprover,
isTelegramExecApprovalAuthorizedSender,
@ -102,18 +99,6 @@ import {
} from "./model-buttons.js";
import { buildInlineKeyboard } from "./send.js";
function parseApprovalCallbackId(data: string): string | null {
const trimmed = data.trim();
if (!trimmed.startsWith("/approve")) {
return null;
}
const tokens = trimmed.split(/\s+/).filter(Boolean);
if (tokens.length < 3) {
return null;
}
return tokens[1] ?? null;
}
export const registerTelegramHandlers = ({
cfg,
accountId,
@ -173,7 +158,20 @@ export const registerTelegramHandlers = ({
botUsername?: string;
};
const resolveTelegramDebounceLane = (msg: Message): TelegramDebounceLane => {
return msg.forward_origin ? "forward" : "default";
const forwardMeta = msg as {
forward_origin?: unknown;
forward_from?: unknown;
forward_from_chat?: unknown;
forward_sender_name?: unknown;
forward_date?: unknown;
};
return (forwardMeta.forward_origin ??
forwardMeta.forward_from ??
forwardMeta.forward_from_chat ??
forwardMeta.forward_sender_name ??
forwardMeta.forward_date)
? "forward"
: "default";
};
const buildSyntheticTextMessage = (params: {
base: Message;
@ -190,20 +188,15 @@ export const registerTelegramHandlers = ({
...(params.date != null ? { date: params.date } : {}),
});
const buildSyntheticContext = (
ctx: TelegramSyntheticContextSource,
ctx: Pick<TelegramContext, "me"> & { getFile?: unknown },
message: Message,
): TelegramContext => {
const getFile =
typeof ctx.getFile === "function" ? ctx.getFile.bind(ctx as object) : async () => ({});
typeof ctx.getFile === "function"
? (ctx.getFile as TelegramContext["getFile"]).bind(ctx as object)
: async () => ({});
return { message, me: ctx.me, getFile };
};
const isSelfAuthoredTelegramMessage = (
ctx: Pick<TelegramContext, "me">,
message: Message,
): boolean => {
const botId = ctx.me?.id;
return typeof botId === "number" && message.from?.id === botId;
};
const inboundDebouncer = createInboundDebouncer<TelegramDebounceEntry>({
debounceMs,
resolveDebounceMs: (entry) =>
@ -633,7 +626,10 @@ export const registerTelegramHandlers = ({
type TelegramEventAuthorizationMode = "reaction" | "callback-scope" | "callback-allowlist";
type TelegramEventAuthorizationResult = { allowed: true } | { allowed: false; reason: string };
type TelegramEventAuthorizationContext = TelegramGroupAllowContext & { dmPolicy: DmPolicy };
const getChat: TelegramGetChat = bot.api.getChat.bind(bot.api);
const getChat =
typeof (bot.api as { getChat?: unknown }).getChat === "function"
? ((bot.api as { getChat: TelegramGetChat }).getChat.bind(bot.api) as TelegramGetChat)
: undefined;
const TELEGRAM_EVENT_AUTH_RULES: Record<
TelegramEventAuthorizationMode,
@ -1099,10 +1095,10 @@ export const registerTelegramHandlers = ({
if (shouldSkipUpdate(ctx)) {
return;
}
const answerCallbackQuery = () =>
typeof ctx.answerCallbackQuery === "function"
? ctx.answerCallbackQuery()
: bot.api.answerCallbackQuery(callback.id);
const answerCallbackQuery =
typeof (ctx as { answerCallbackQuery?: unknown }).answerCallbackQuery === "function"
? () => ctx.answerCallbackQuery()
: () => bot.api.answerCallbackQuery(callback.id);
// Answer immediately to prevent Telegram from retrying while we process
await withTelegramApiErrorLogging({
operation: "answerCallbackQuery",
@ -1118,26 +1114,41 @@ export const registerTelegramHandlers = ({
const editCallbackMessage = async (
text: string,
params?: Parameters<typeof bot.api.editMessageText>[3],
) =>
typeof ctx.editMessageText === "function"
? ctx.editMessageText(text, params)
: bot.api.editMessageText(
callbackMessage.chat.id,
callbackMessage.message_id,
text,
params,
);
) => {
const editTextFn = (ctx as { editMessageText?: unknown }).editMessageText;
if (typeof editTextFn === "function") {
return await ctx.editMessageText(text, params);
}
return await bot.api.editMessageText(
callbackMessage.chat.id,
callbackMessage.message_id,
text,
params,
);
};
const clearCallbackButtons = async () => {
const emptyKeyboard = { inline_keyboard: [] };
const replyMarkup = { reply_markup: emptyKeyboard };
if (typeof ctx.editMessageReplyMarkup === "function") {
const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown })
.editMessageReplyMarkup;
if (typeof editReplyMarkupFn === "function") {
return await ctx.editMessageReplyMarkup(replyMarkup);
}
return await bot.api.editMessageReplyMarkup(
callbackMessage.chat.id,
callbackMessage.message_id,
replyMarkup,
);
const apiEditReplyMarkupFn = (bot.api as { editMessageReplyMarkup?: unknown })
.editMessageReplyMarkup;
if (typeof apiEditReplyMarkupFn === "function") {
return await bot.api.editMessageReplyMarkup(
callbackMessage.chat.id,
callbackMessage.message_id,
replyMarkup,
);
}
// Fallback path for older clients that do not expose editMessageReplyMarkup.
const messageText = callbackMessage.text ?? callbackMessage.caption;
if (typeof messageText !== "string" || messageText.trim().length === 0) {
return undefined;
}
return await editCallbackMessage(messageText, replyMarkup);
};
const editCallbackButtons = async (
buttons: Array<
@ -1146,7 +1157,9 @@ export const registerTelegramHandlers = ({
) => {
const keyboard = buildInlineKeyboard(buttons) ?? { inline_keyboard: [] };
const replyMarkup = { reply_markup: keyboard };
if (typeof ctx.editMessageReplyMarkup === "function") {
const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown })
.editMessageReplyMarkup;
if (typeof editReplyMarkupFn === "function") {
return await ctx.editMessageReplyMarkup(replyMarkup);
}
return await bot.api.editMessageReplyMarkup(
@ -1156,22 +1169,28 @@ export const registerTelegramHandlers = ({
);
};
const deleteCallbackMessage = async () => {
return typeof ctx.deleteMessage === "function"
? ctx.deleteMessage()
: bot.api.deleteMessage(callbackMessage.chat.id, callbackMessage.message_id);
const deleteFn = (ctx as { deleteMessage?: unknown }).deleteMessage;
if (typeof deleteFn === "function") {
return await ctx.deleteMessage();
}
return await bot.api.deleteMessage(callbackMessage.chat.id, callbackMessage.message_id);
};
const replyToCallbackChat = async (
text: string,
params?: Parameters<typeof bot.api.sendMessage>[2],
) =>
typeof ctx.reply === "function"
? ctx.reply(text, params)
: bot.api.sendMessage(callbackMessage.chat.id, text, params);
) => {
const replyFn = (ctx as { reply?: unknown }).reply;
if (typeof replyFn === "function") {
return await ctx.reply(text, params);
}
return await bot.api.sendMessage(callbackMessage.chat.id, text, params);
};
const chatId = callbackMessage.chat.id;
const isGroup =
callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup";
const isApprovalCallback = APPROVE_CALLBACK_DATA_RE.test(data);
const approvalCallback = parseExecApprovalCommandText(data);
const isApprovalCallback = approvalCallback !== null;
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg,
accountId,
@ -1219,7 +1238,6 @@ export const registerTelegramHandlers = ({
}
const senderId = callback.from?.id ? String(callback.from.id) : "";
const senderUsername = callback.from?.username ?? "";
// DM callbacks must enforce the same sender authorization gate as normal DM commands.
const authorizationMode: TelegramEventAuthorizationMode =
!isGroup || (!execApprovalButtonsEnabled && inlineButtonsScope === "allowlist")
? "callback-allowlist"
@ -1303,21 +1321,44 @@ export const registerTelegramHandlers = ({
}
const runtimeCfg = telegramDeps.loadConfig();
if (isApprovalCallback) {
const approvalId = parseApprovalCallbackId(data);
const isPluginApprovalCallback = approvalId?.startsWith("plugin:") ?? false;
if (!isTelegramExecApprovalAuthorizedSender({ cfg: runtimeCfg, accountId, senderId })) {
if (approvalCallback) {
const isPluginApproval = approvalCallback.approvalId.startsWith("plugin:");
const pluginApprovalAuthorizedSender = isTelegramExecApprovalApprover({
cfg: runtimeCfg,
accountId,
senderId,
});
const execApprovalAuthorizedSender = isTelegramExecApprovalAuthorizedSender({
cfg: runtimeCfg,
accountId,
senderId,
});
const authorizedApprovalSender = isPluginApproval
? pluginApprovalAuthorizedSender
: execApprovalAuthorizedSender || pluginApprovalAuthorizedSender;
if (!authorizedApprovalSender) {
logVerbose(
`Blocked telegram exec approval callback from ${senderId || "unknown"} (not an approver)`,
`Blocked telegram approval callback from ${senderId || "unknown"} (not authorized)`,
);
return;
}
if (
isPluginApprovalCallback &&
!isTelegramExecApprovalApprover({ cfg: runtimeCfg, accountId, senderId })
) {
try {
// Resolve approval callbacks directly so Telegram approvers are not forced through
// the generic chat-command authorization path.
await (telegramDeps.resolveExecApproval ?? resolveTelegramExecApproval)({
cfg: runtimeCfg,
approvalId: approvalCallback.approvalId,
decision: approvalCallback.decision,
senderId,
allowPluginFallback: pluginApprovalAuthorizedSender,
});
} catch (resolveErr) {
const errStr = String(resolveErr);
logVerbose(
`Blocked telegram plugin approval callback from ${senderId || "unknown"} (not an explicit approver)`,
`telegram: failed to resolve approval callback ${approvalCallback.approvalId}: ${errStr}`,
);
await replyToCallbackChat(
"❌ Failed to submit approval. Please try again or contact an admin.",
);
return;
}
@ -1326,12 +1367,14 @@ export const registerTelegramHandlers = ({
} catch (editErr) {
const errStr = String(editErr);
if (
!errStr.includes("message is not modified") &&
!errStr.includes("there is no text in the message to edit")
errStr.includes("message is not modified") ||
errStr.includes("there is no text in the message to edit")
) {
logVerbose(`telegram: failed to clear approval callback buttons: ${errStr}`);
return;
}
logVerbose(`telegram: failed to clear approval callback buttons: ${errStr}`);
}
return;
}
const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/);
@ -1389,7 +1432,7 @@ export const registerTelegramHandlers = ({
runtimeCfg,
sessionState.agentId,
);
const { byProvider, providers, modelNames } = modelData;
const { byProvider, providers } = modelData;
const editMessageWithButtons = async (
text: string,
@ -1464,7 +1507,6 @@ export const registerTelegramHandlers = ({
currentPage: safePage,
totalPages,
pageSize,
modelNames,
});
const text = formatModelsAvailableHeader({
provider,
@ -1551,14 +1593,14 @@ export const registerTelegramHandlers = ({
return;
}
const nativeCommandText = parseTelegramNativeCommandCallbackData(data);
const nativeCallbackCommand = parseTelegramNativeCommandCallbackData(data);
const syntheticMessage = buildSyntheticTextMessage({
base: withResolvedTelegramForumFlag(callbackMessage, isForum),
from: callback.from,
text: nativeCommandText ?? data,
text: nativeCallbackCommand ?? data,
});
await processMessage(buildSyntheticContext(ctx, syntheticMessage), [], storeAllowFrom, {
commandSource: nativeCommandText ? "native" : undefined,
...(nativeCallbackCommand ? { commandSource: "native" as const } : {}),
forceWasMentioned: true,
messageIdOverride: callback.id,
});
@ -1724,9 +1766,6 @@ export const registerTelegramHandlers = ({
if (!msg) {
return;
}
if (isSelfAuthoredTelegramMessage(ctx, msg)) {
return;
}
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const isForum = await resolveTelegramForumFlag({
chatId: msg.chat.id,
@ -1736,6 +1775,11 @@ export const registerTelegramHandlers = ({
getChat,
});
const normalizedMsg = withResolvedTelegramForumFlag(msg, isForum);
// Bot-authored message updates can be echoed back by Telegram. Skip them here
// and rely on the dedicated channel_post handler for channel-originated posts.
if (normalizedMsg.from?.id != null && normalizedMsg.from.id === ctx.me?.id) {
return;
}
await handleInboundMessageLike({
ctxForDedupe: ctx,
ctx: buildSyntheticContext(ctx, normalizedMsg),

View File

@ -273,6 +273,10 @@ const systemEventsHoisted = vi.hoisted(() => ({
}));
export const enqueueSystemEventSpy: MockFn<TelegramBotDeps["enqueueSystemEvent"]> =
systemEventsHoisted.enqueueSystemEventSpy;
const execApprovalHoisted = vi.hoisted(() => ({
resolveExecApprovalSpy: vi.fn(async () => undefined),
}));
export const resolveExecApprovalSpy = execApprovalHoisted.resolveExecApprovalSpy;
vi.doMock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-runtime")>();
@ -415,6 +419,9 @@ export const telegramBotDepsForTest: TelegramBotDeps = {
listSkillCommandsForAgents:
listSkillCommandsForAgents as TelegramBotDeps["listSkillCommandsForAgents"],
wasSentByBot: wasSentByBot as TelegramBotDeps["wasSentByBot"],
resolveExecApproval: resolveExecApprovalSpy as NonNullable<
TelegramBotDeps["resolveExecApproval"]
>,
};
vi.doMock("./bot.runtime.js", () => telegramBotRuntimeForTest);
@ -508,6 +515,8 @@ beforeEach(() => {
await opts?.onReplyStart?.();
return undefined;
});
resolveExecApprovalSpy.mockReset();
resolveExecApprovalSpy.mockResolvedValue(undefined);
dispatchReplyWithBufferedBlockDispatcher.mockReset();
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async (params: DispatchReplyHarnessParams) =>

View File

@ -575,6 +575,37 @@ describe("createTelegramBot", () => {
});
});
it("ignores group self-authored message updates instead of re-processing bot output", async () => {
await withIsolatedStateDirAsync(async () => {
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "pairing" } },
});
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockClear();
sendMessageSpy.mockClear();
replySpy.mockClear();
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: -1001234, type: "supergroup", title: "OpenClaw Ops" },
message_id: 1884,
date: 1736380800,
from: { id: 7, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot" },
text: "approval card update",
},
me: { id: 7, is_bot: true, first_name: "OpenClaw", username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(upsertChannelPairingRequest).not.toHaveBeenCalled();
expect(sendMessageSpy).not.toHaveBeenCalled();
expect(replySpy).not.toHaveBeenCalled();
});
});
it("blocks unauthorized DM media before download and sends pairing reply", async () => {
await withIsolatedStateDirAsync(async () => {
loadConfig.mockReturnValue({

View File

@ -21,6 +21,7 @@ const {
listSkillCommandsForAgents,
onSpy,
replySpy,
resolveExecApprovalSpy,
sendMessageSpy,
setMyCommandsSpy,
telegramBotDepsForTest,
@ -215,6 +216,7 @@ describe("createTelegramBot", () => {
it("blocks callback_query when inline buttons are allowlist-only and sender not authorized", async () => {
onSpy.mockClear();
replySpy.mockClear();
sendMessageSpy.mockClear();
createTelegramBot({
token: "tok",
@ -366,6 +368,7 @@ describe("createTelegramBot", () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
@ -417,6 +420,24 @@ describe("createTelegramBot", () => {
expect(chatId).toBe(1234);
expect(messageId).toBe(21);
expect(replyMarkup).toEqual({ reply_markup: { inline_keyboard: [] } });
expect(resolveExecApprovalSpy).toHaveBeenCalledWith({
cfg: expect.objectContaining({
channels: expect.objectContaining({
telegram: expect.objectContaining({
execApprovals: expect.objectContaining({
enabled: true,
approvers: ["9"],
target: "dm",
}),
}),
}),
}),
approvalId: "138e9b8c",
decision: "allow-once",
allowPluginFallback: true,
senderId: "9",
});
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-style");
});
@ -466,10 +487,73 @@ describe("createTelegramBot", () => {
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-capability-free");
});
it("resolves plugin approval callbacks through the shared approval resolver", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(callbackHandler).toBeDefined();
await callbackHandler({
callbackQuery: {
id: "cbq-plugin-approve",
data: "/approve plugin:138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 24,
text: "Plugin approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(resolveExecApprovalSpy).toHaveBeenCalledWith({
cfg: expect.objectContaining({
channels: expect.objectContaining({
telegram: expect.objectContaining({
execApprovals: expect.objectContaining({
enabled: true,
approvers: ["9"],
target: "dm",
}),
}),
}),
}),
approvalId: "plugin:138e9b8c",
decision: "allow-once",
allowPluginFallback: true,
senderId: "9",
});
expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-plugin-approve");
});
it("blocks approval callbacks from telegram users who are not exec approvers", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
@ -508,9 +592,186 @@ describe("createTelegramBot", () => {
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(editMessageTextSpy).not.toHaveBeenCalled();
expect(resolveExecApprovalSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-blocked");
});
it("does not leak raw approval callback errors back into Telegram chat", async () => {
onSpy.mockClear();
sendMessageSpy.mockClear();
resolveExecApprovalSpy.mockClear();
resolveExecApprovalSpy.mockRejectedValueOnce(new Error("gateway secret detail"));
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await callbackHandler({
callbackQuery: {
id: "cbq-approve-error",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 25,
text: "Approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(sendMessageSpy.mock.calls[0]?.[1]).toBe(
"❌ Failed to submit approval. Please try again or contact an admin.",
);
});
it("allows exec approval callbacks from target-only Telegram recipients", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
loadConfig.mockReturnValue({
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "9" }],
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(callbackHandler).toBeDefined();
await callbackHandler({
callbackQuery: {
id: "cbq-approve-target",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 23,
text: "Approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(resolveExecApprovalSpy).toHaveBeenCalledWith({
cfg: expect.objectContaining({
approvals: expect.objectContaining({
exec: expect.objectContaining({
enabled: true,
mode: "targets",
}),
}),
}),
approvalId: "138e9b8c",
decision: "allow-once",
allowPluginFallback: false,
senderId: "9",
});
expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-target");
});
it("does not allow target-only recipients to use legacy plugin fallback ids", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
resolveExecApprovalSpy.mockClear();
replySpy.mockClear();
resolveExecApprovalSpy.mockRejectedValueOnce(new Error("unknown or expired approval id"));
loadConfig.mockReturnValue({
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "9" }],
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(callbackHandler).toBeDefined();
await callbackHandler({
callbackQuery: {
id: "cbq-legacy-plugin-fallback-blocked",
data: "/approve 138e9b8c allow-once",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 25,
text: "Legacy plugin approval required.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(resolveExecApprovalSpy).toHaveBeenCalledWith({
cfg: expect.objectContaining({
approvals: expect.objectContaining({
exec: expect.objectContaining({
enabled: true,
mode: "targets",
}),
}),
}),
approvalId: "138e9b8c",
decision: "allow-once",
allowPluginFallback: false,
senderId: "9",
});
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(replySpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledWith(
1234,
"❌ Failed to submit approval. Please try again or contact an admin.",
undefined,
);
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-legacy-plugin-fallback-blocked");
});
it("keeps plugin approval callback buttons for target-only recipients", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();

View File

@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { buildTelegramInteractiveButtons, resolveTelegramInlineButtons } from "./button-types.js";
describe("buildTelegramInteractiveButtons", () => {
it("maps shared buttons and selects into Telegram inline rows", () => {
expect(
buildTelegramInteractiveButtons({
blocks: [
{
type: "buttons",
buttons: [
{ label: "Approve", value: "approve", style: "success" },
{ label: "Reject", value: "reject", style: "danger" },
{ label: "Later", value: "later" },
{ label: "Archive", value: "archive" },
],
},
{
type: "select",
options: [{ label: "Alpha", value: "alpha" }],
},
],
}),
).toEqual([
[
{ text: "Approve", callback_data: "approve", style: "success" },
{ text: "Reject", callback_data: "reject", style: "danger" },
{ text: "Later", callback_data: "later", style: undefined },
],
[{ text: "Archive", callback_data: "archive", style: undefined }],
[{ text: "Alpha", callback_data: "alpha", style: undefined }],
]);
});
it("drops buttons whose callback payload exceeds Telegram limits", () => {
expect(
buildTelegramInteractiveButtons({
blocks: [
{
type: "buttons",
buttons: [
{ label: "Keep", value: "ok" },
{ label: "Drop", value: `x${"y".repeat(80)}` },
],
},
],
}),
).toEqual([[{ text: "Keep", callback_data: "ok", style: undefined }]]);
});
});
describe("resolveTelegramInlineButtons", () => {
it("prefers explicit buttons over shared interactive blocks", () => {
const explicit = [[{ text: "Keep", callback_data: "keep" }]] as const;
expect(
resolveTelegramInlineButtons({
buttons: explicit,
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Override", value: "override" }],
},
],
},
}),
).toBe(explicit);
});
it("derives buttons from raw interactive payloads", () => {
expect(
resolveTelegramInlineButtons({
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Retry", value: "retry", style: "primary" }],
},
],
},
}),
).toEqual([[{ text: "Retry", callback_data: "retry", style: "primary" }]]);
});
});

View File

@ -2,7 +2,6 @@ import {
buildDmGroupAccountAllowlistAdapter,
createNestedAllowlistOverrideResolver,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import { createApproverRestrictedNativeApprovalAdapter } from "openclaw/plugin-sdk/approval-runtime";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
@ -42,6 +41,7 @@ import {
import { resolveTelegramAutoThreadId } from "./action-threading.js";
import { lookupTelegramChatId } from "./api-fetch.js";
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
import { telegramNativeApprovalAdapter } from "./approval-native.js";
import * as auditModule from "./audit.js";
import { buildTelegramGroupPeerId } from "./bot/helpers.js";
import { telegramMessageActions as telegramMessageActionsImpl } from "./channel-actions.js";
@ -49,6 +49,7 @@ import {
listTelegramDirectoryGroupsFromConfig,
listTelegramDirectoryPeersFromConfig,
} from "./directory-config.js";
import { buildTelegramExecApprovalPendingPayload } from "./exec-approval-forwarding.js";
import {
getTelegramExecApprovalApprovers,
isTelegramExecApprovalApprover,
@ -422,25 +423,6 @@ async function resolveTelegramTargets(params: {
);
}
const telegramNativeApprovalAdapter = createApproverRestrictedNativeApprovalAdapter({
channel: "telegram",
channelLabel: "Telegram",
listAccountIds: listTelegramAccountIds,
hasApprovers: ({ cfg, accountId }) =>
getTelegramExecApprovalApprovers({ cfg, accountId }).length > 0,
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
isTelegramExecApprovalAuthorizedSender({ cfg, accountId, senderId }),
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) =>
isTelegramExecApprovalApprover({ cfg, accountId, senderId }),
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
isTelegramExecApprovalClientEnabled({ cfg, accountId }),
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
resolveTelegramExecApprovalTarget({ cfg, accountId }),
requireMatchingTurnSourceChannel: true,
resolveSuppressionAccountId: ({ target, request }) =>
target.accountId?.trim() || request.request.turnSourceAccountId?.trim() || undefined,
});
const resolveTelegramAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({
resolveRecord: (account: ResolvedTelegramAccount) => account.config.groups,
outerLabel: (groupId) => groupId,
@ -595,6 +577,13 @@ export const telegramPlugin = createChatChannelPlugin({
auth: telegramNativeApprovalAdapter.auth,
approvals: {
delivery: telegramNativeApprovalAdapter.delivery,
native: telegramNativeApprovalAdapter.native,
render: {
exec: {
buildPendingPayload: ({ request, nowMs }) =>
buildTelegramExecApprovalPendingPayload({ request, nowMs }),
},
},
},
directory: createChannelDirectoryAdapter({
listPeers: async (params) => listTelegramDirectoryPeersFromConfig(params),

View File

@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const gatewayRuntimeHoisted = vi.hoisted(() => ({
requestSpy: vi.fn(),
startSpy: vi.fn(),
stopSpy: vi.fn(),
stopAndWaitSpy: vi.fn(async () => undefined),
createClientSpy: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({
createOperatorApprovalsGatewayClient: gatewayRuntimeHoisted.createClientSpy,
}));
describe("resolveTelegramExecApproval", () => {
beforeEach(() => {
gatewayRuntimeHoisted.requestSpy.mockReset();
gatewayRuntimeHoisted.startSpy.mockReset();
gatewayRuntimeHoisted.stopSpy.mockReset();
gatewayRuntimeHoisted.stopAndWaitSpy.mockReset().mockResolvedValue(undefined);
gatewayRuntimeHoisted.createClientSpy.mockReset().mockImplementation((opts) => ({
start: () => {
gatewayRuntimeHoisted.startSpy();
opts.onHelloOk?.();
},
request: gatewayRuntimeHoisted.requestSpy,
stop: gatewayRuntimeHoisted.stopSpy,
stopAndWait: gatewayRuntimeHoisted.stopAndWaitSpy,
}));
});
it("routes plugin approval ids through plugin.approval.resolve", async () => {
const { resolveTelegramExecApproval } = await import("./exec-approval-resolver.js");
await resolveTelegramExecApproval({
cfg: {} as never,
approvalId: "plugin:abc123",
decision: "allow-once",
senderId: "9",
});
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenCalledWith("plugin.approval.resolve", {
id: "plugin:abc123",
decision: "allow-once",
});
});
it("falls back to plugin.approval.resolve when exec approval ids are unknown", async () => {
gatewayRuntimeHoisted.requestSpy
.mockRejectedValueOnce(new Error("unknown or expired approval id"))
.mockResolvedValueOnce(undefined);
const { resolveTelegramExecApproval } = await import("./exec-approval-resolver.js");
await resolveTelegramExecApproval({
cfg: {} as never,
approvalId: "legacy-plugin-123",
decision: "allow-always",
senderId: "9",
allowPluginFallback: true,
});
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenNthCalledWith(1, "exec.approval.resolve", {
id: "legacy-plugin-123",
decision: "allow-always",
});
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenNthCalledWith(
2,
"plugin.approval.resolve",
{
id: "legacy-plugin-123",
decision: "allow-always",
},
);
});
it("falls back to plugin.approval.resolve for structured approval-not-found errors", async () => {
const err = new Error("approval not found");
(err as Error & { gatewayCode?: string; details?: { reason?: string } }).gatewayCode =
"INVALID_REQUEST";
(err as Error & { gatewayCode?: string; details?: { reason?: string } }).details = {
reason: "APPROVAL_NOT_FOUND",
};
gatewayRuntimeHoisted.requestSpy.mockRejectedValueOnce(err).mockResolvedValueOnce(undefined);
const { resolveTelegramExecApproval } = await import("./exec-approval-resolver.js");
await resolveTelegramExecApproval({
cfg: {} as never,
approvalId: "legacy-plugin-123",
decision: "allow-always",
senderId: "9",
allowPluginFallback: true,
});
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenNthCalledWith(1, "exec.approval.resolve", {
id: "legacy-plugin-123",
decision: "allow-always",
});
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenNthCalledWith(
2,
"plugin.approval.resolve",
{
id: "legacy-plugin-123",
decision: "allow-always",
},
);
});
it("does not fall back to plugin.approval.resolve without explicit permission", async () => {
gatewayRuntimeHoisted.requestSpy.mockRejectedValueOnce(
new Error("unknown or expired approval id"),
);
const { resolveTelegramExecApproval } = await import("./exec-approval-resolver.js");
await expect(
resolveTelegramExecApproval({
cfg: {} as never,
approvalId: "legacy-plugin-123",
decision: "allow-always",
senderId: "9",
}),
).rejects.toThrow("unknown or expired approval id");
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenCalledTimes(1);
expect(gatewayRuntimeHoisted.requestSpy).toHaveBeenCalledWith("exec.approval.resolve", {
id: "legacy-plugin-123",
decision: "allow-always",
});
});
});

View File

@ -0,0 +1,114 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { createOperatorApprovalsGatewayClient } from "openclaw/plugin-sdk/gateway-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/infra-runtime";
export type ResolveTelegramExecApprovalParams = {
cfg: OpenClawConfig;
approvalId: string;
decision: ExecApprovalReplyDecision;
senderId?: string | null;
allowPluginFallback?: boolean;
gatewayUrl?: string;
};
const INVALID_REQUEST = "INVALID_REQUEST";
const APPROVAL_NOT_FOUND = "APPROVAL_NOT_FOUND";
function readErrorCode(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value : null;
}
function readApprovalNotFoundDetailsReason(value: unknown): string | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const reason = (value as { reason?: unknown }).reason;
return typeof reason === "string" && reason.trim() ? reason : null;
}
function isApprovalNotFoundError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false;
}
const gatewayCode = readErrorCode((err as { gatewayCode?: unknown }).gatewayCode);
if (gatewayCode === APPROVAL_NOT_FOUND) {
return true;
}
const detailsReason = readApprovalNotFoundDetailsReason((err as { details?: unknown }).details);
if (gatewayCode === INVALID_REQUEST && detailsReason === APPROVAL_NOT_FOUND) {
return true;
}
return /unknown or expired approval id/i.test(err.message);
}
export async function resolveTelegramExecApproval(
params: ResolveTelegramExecApprovalParams,
): Promise<void> {
let readySettled = false;
let resolveReady!: () => void;
let rejectReady!: (err: unknown) => void;
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
const markReady = () => {
if (readySettled) {
return;
}
readySettled = true;
resolveReady();
};
const failReady = (err: unknown) => {
if (readySettled) {
return;
}
readySettled = true;
rejectReady(err);
};
const gatewayClient = await createOperatorApprovalsGatewayClient({
config: params.cfg,
gatewayUrl: params.gatewayUrl,
clientDisplayName: `Telegram approval (${params.senderId?.trim() || "unknown"})`,
onHelloOk: () => {
markReady();
},
onConnectError: (err) => {
failReady(err);
},
onClose: (code, reason) => {
// Once onHelloOk resolves `ready`, in-flight request failures must come from
// gatewayClient.request() itself; failReady only covers the pre-ready phase.
failReady(new Error(`gateway closed (${code}): ${reason}`));
},
});
try {
gatewayClient.start();
await ready;
const requestApproval = async (method: "exec.approval.resolve" | "plugin.approval.resolve") => {
await gatewayClient.request(method, {
id: params.approvalId,
decision: params.decision,
});
};
if (params.approvalId.startsWith("plugin:")) {
await requestApproval("plugin.approval.resolve");
} else {
try {
await requestApproval("exec.approval.resolve");
} catch (err) {
if (!params.allowPluginFallback || !isApprovalNotFoundError(err)) {
throw err;
}
await requestApproval("plugin.approval.resolve");
}
}
} finally {
await gatewayClient.stopAndWait().catch(() => {
gatewayClient.stop();
});
}
}

View File

@ -1,5 +1,9 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { updateSessionStore } from "../../../src/config/sessions.js";
import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js";
const baseRequest = {
@ -17,7 +21,24 @@ const baseRequest = {
expiresAtMs: 61_000,
};
function createHandler(cfg: OpenClawConfig) {
const pluginRequest = {
id: "plugin:9f1c7d5d-b1fb-46ef-ac45-662723b65bb7",
request: {
title: "Plugin Approval Required",
description: "Allow plugin access",
pluginId: "git-tools",
agentId: "main",
sessionKey: "agent:main:telegram:group:-1003841603622:topic:928",
turnSourceChannel: "telegram",
turnSourceTo: "-1003841603622",
turnSourceThreadId: "928",
turnSourceAccountId: "default",
},
createdAtMs: 1000,
expiresAtMs: 61_000,
};
function createHandler(cfg: OpenClawConfig, accountId = "default") {
const sendTyping = vi.fn().mockResolvedValue({ ok: true });
const sendMessage = vi
.fn()
@ -27,7 +48,7 @@ function createHandler(cfg: OpenClawConfig) {
const handler = new TelegramExecApprovalHandler(
{
token: "tg-token",
accountId: "default",
accountId,
cfg,
},
{
@ -75,16 +96,17 @@ describe("TelegramExecApprovalHandler", () => {
{
text: "Allow Once",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once",
style: "success",
},
{
text: "Allow Always",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 always",
style: "primary",
},
],
[
{
text: "Deny",
callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 deny",
style: "danger",
},
],
],
@ -153,4 +175,136 @@ describe("TelegramExecApprovalHandler", () => {
}),
);
});
it("delivers plugin approvals through the shared native delivery planner", async () => {
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["8460800771"],
target: "dm",
},
},
},
} as OpenClawConfig;
const { handler, sendMessage } = createHandler(cfg);
await handler.handleRequested(pluginRequest);
const [chatId, text, options] = sendMessage.mock.calls[0] ?? [];
expect(chatId).toBe("8460800771");
expect(text).toContain("Plugin approval required");
expect(options).toEqual(
expect.objectContaining({
accountId: "default",
buttons: expect.arrayContaining([
expect.arrayContaining([
expect.objectContaining({
callback_data: "/approve plugin:9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once",
}),
]),
]),
}),
);
});
it("does not deliver plugin approvals for a different Telegram account", async () => {
const cfg = {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["8460800771"],
target: "dm",
},
accounts: {
secondary: {
execApprovals: {
enabled: true,
approvers: ["999"],
target: "dm",
},
},
},
},
},
} as OpenClawConfig;
const { handler, sendMessage } = createHandler(cfg);
await handler.handleRequested({
...pluginRequest,
request: {
...pluginRequest.request,
turnSourceAccountId: "secondary",
},
});
expect(sendMessage).not.toHaveBeenCalled();
});
it("falls back to the session-bound Telegram account when turn source account is missing", async () => {
const sessionStoreDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tg-approvals-"));
const storePath = path.join(sessionStoreDir, "sessions.json");
try {
await updateSessionStore(storePath, (store) => {
store[baseRequest.request.sessionKey] = {
sessionId: "session-secondary",
updatedAt: Date.now(),
deliveryContext: {
channel: "telegram",
to: "-1003841603622",
accountId: "secondary",
threadId: 928,
},
};
});
const cfg = {
session: { store: storePath },
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["8460800771"],
target: "channel",
},
accounts: {
secondary: {
execApprovals: {
enabled: true,
approvers: ["999"],
target: "channel",
},
},
},
},
},
} as OpenClawConfig;
const defaultHandler = createHandler(cfg, "default");
const secondaryHandler = createHandler(cfg, "secondary");
const request = {
...baseRequest,
request: {
...baseRequest.request,
turnSourceAccountId: null,
},
};
await defaultHandler.handler.handleRequested(request);
await secondaryHandler.handler.handleRequested(request);
expect(defaultHandler.sendMessage).not.toHaveBeenCalled();
expect(secondaryHandler.sendMessage).toHaveBeenCalledWith(
"-1003841603622",
expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"),
expect.objectContaining({
accountId: "secondary",
messageThreadId: 928,
}),
);
} finally {
await fs.rm(sessionStoreDir, { recursive: true, force: true });
}
});
});

View File

@ -1,43 +1,100 @@
import { buildPluginApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
createExecApprovalChannelRuntime,
type ExecApprovalChannelRuntime,
resolveChannelNativeApprovalDeliveryPlan,
resolveExecApprovalSessionTarget,
} from "openclaw/plugin-sdk/infra-runtime";
import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime";
import {
buildExecApprovalPendingReplyPayload,
resolveExecApprovalCommandDisplay,
resolveExecApprovalSessionTarget,
type ExecApprovalPendingReplyParams,
type ExecApprovalRequest,
type ExecApprovalResolved,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { GatewayClient } from "openclaw/plugin-sdk/gateway-runtime";
import { createOperatorApprovalsGatewayClient } from "openclaw/plugin-sdk/gateway-runtime";
import type { EventFrame } from "openclaw/plugin-sdk/gateway-runtime";
import { normalizeAccountId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
} from "openclaw/plugin-sdk/infra-runtime";
import type {
ExecApprovalRequest,
ExecApprovalResolved,
PluginApprovalRequest,
PluginApprovalResolved,
} from "openclaw/plugin-sdk/infra-runtime";
import { parseAgentSessionKey, normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { compileSafeRegex, testRegexWithBoundedInput } from "openclaw/plugin-sdk/security-runtime";
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
import { telegramNativeApprovalAdapter } from "./approval-native.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import {
getTelegramExecApprovalApprovers,
resolveTelegramExecApprovalConfig,
resolveTelegramExecApprovalTarget,
} from "./exec-approvals.js";
import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js";
const log = createSubsystemLogger("telegram/exec-approvals");
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved;
type ApprovalKind = "exec" | "plugin";
type PendingMessage = {
chatId: string;
messageId: string;
};
type PendingApproval = {
timeoutId: NodeJS.Timeout;
messages: PendingMessage[];
};
function isExecApprovalRequest(request: ApprovalRequest): request is ExecApprovalRequest {
return "command" in request.request;
}
type TelegramApprovalTarget = {
to: string;
threadId?: number;
};
function toExecLikeRequest(request: ApprovalRequest): ExecApprovalRequest {
if (isExecApprovalRequest(request)) {
return request;
}
return {
id: request.id,
request: {
command: request.request.title,
agentId: request.request.agentId ?? undefined,
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 resolveBoundTelegramAccountId(params: {
cfg: OpenClawConfig;
request: ApprovalRequest;
}): string | null {
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase();
const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || undefined;
if (turnSourceChannel === "telegram") {
if (turnSourceAccountId) {
return turnSourceAccountId;
}
}
const allowSessionAccountFallback = turnSourceChannel === "telegram" && !turnSourceAccountId;
const sessionTarget = resolveExecApprovalSessionTarget({
cfg: params.cfg,
request: toExecLikeRequest(params.request),
turnSourceChannel: allowSessionAccountFallback
? undefined
: (params.request.request.turnSourceChannel ?? undefined),
turnSourceTo: allowSessionAccountFallback
? undefined
: (params.request.request.turnSourceTo ?? undefined),
turnSourceAccountId: allowSessionAccountFallback ? undefined : turnSourceAccountId,
turnSourceThreadId: allowSessionAccountFallback
? undefined
: (params.request.request.turnSourceThreadId ?? undefined),
});
if (!sessionTarget || sessionTarget.channel !== "telegram") {
return null;
}
return sessionTarget.accountId?.trim() || null;
}
export type TelegramExecApprovalHandlerOpts = {
token: string;
@ -57,7 +114,7 @@ export type TelegramExecApprovalHandlerDeps = {
function matchesFilters(params: {
cfg: OpenClawConfig;
accountId: string;
request: ExecApprovalRequest;
request: ApprovalRequest;
}): boolean {
const config = resolveTelegramExecApprovalConfig({
cfg: params.cfg,
@ -97,6 +154,16 @@ function matchesFilters(params: {
return false;
}
}
const boundAccountId = resolveBoundTelegramAccountId({
cfg: params.cfg,
request: params.request,
});
if (
boundAccountId &&
normalizeAccountId(boundAccountId) !== normalizeAccountId(params.accountId)
) {
return false;
}
return true;
}
@ -116,78 +183,8 @@ function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string })
);
}
function resolveRequestSessionTarget(params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest;
}): { to: string; accountId?: string; threadId?: number; channel?: string } | null {
return resolveExecApprovalSessionTarget({
cfg: params.cfg,
request: params.request,
turnSourceChannel: params.request.request.turnSourceChannel ?? undefined,
turnSourceTo: params.request.request.turnSourceTo ?? undefined,
turnSourceAccountId: params.request.request.turnSourceAccountId ?? undefined,
turnSourceThreadId: params.request.request.turnSourceThreadId ?? undefined,
});
}
function resolveTelegramSourceTarget(params: {
cfg: OpenClawConfig;
accountId: string;
request: ExecApprovalRequest;
}): TelegramApprovalTarget | null {
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
const turnSourceTo = params.request.request.turnSourceTo?.trim() || "";
const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || "";
if (turnSourceChannel === "telegram" && turnSourceTo) {
if (
turnSourceAccountId &&
normalizeAccountId(turnSourceAccountId) !== normalizeAccountId(params.accountId)
) {
return null;
}
const threadId =
typeof params.request.request.turnSourceThreadId === "number"
? params.request.request.turnSourceThreadId
: typeof params.request.request.turnSourceThreadId === "string"
? Number.parseInt(params.request.request.turnSourceThreadId, 10)
: undefined;
return { to: turnSourceTo, threadId: Number.isFinite(threadId) ? threadId : undefined };
}
const sessionTarget = resolveRequestSessionTarget(params);
if (!sessionTarget || sessionTarget.channel !== "telegram") {
return null;
}
if (
sessionTarget.accountId &&
normalizeAccountId(sessionTarget.accountId) !== normalizeAccountId(params.accountId)
) {
return null;
}
return {
to: sessionTarget.to,
threadId: sessionTarget.threadId,
};
}
function dedupeTargets(targets: TelegramApprovalTarget[]): TelegramApprovalTarget[] {
const seen = new Set<string>();
const deduped: TelegramApprovalTarget[] = [];
for (const target of targets) {
const key = `${target.to}:${target.threadId ?? ""}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(target);
}
return deduped;
}
export class TelegramExecApprovalHandler {
private gatewayClient: GatewayClient | null = null;
private pending = new Map<string, PendingApproval>();
private started = false;
private readonly runtime: ExecApprovalChannelRuntime<ApprovalRequest, ApprovalResolved>;
private readonly nowMs: () => number;
private readonly sendTyping: typeof sendTypingTelegram;
private readonly sendMessage: typeof sendMessageTelegram;
@ -201,9 +198,36 @@ export class TelegramExecApprovalHandler {
this.sendTyping = deps.sendTyping ?? sendTypingTelegram;
this.sendMessage = deps.sendMessage ?? sendMessageTelegram;
this.editReplyMarkup = deps.editReplyMarkup ?? editMessageReplyMarkupTelegram;
this.runtime = createExecApprovalChannelRuntime<
PendingMessage,
ApprovalRequest,
ApprovalResolved
>({
label: "telegram/exec-approvals",
clientDisplayName: `Telegram Exec Approvals (${this.opts.accountId})`,
cfg: this.opts.cfg,
gatewayUrl: this.opts.gatewayUrl,
eventKinds: ["exec", "plugin"],
nowMs: this.nowMs,
isConfigured: () =>
isHandlerConfigured({ cfg: this.opts.cfg, accountId: this.opts.accountId }),
shouldHandle: (request) =>
matchesFilters({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
request,
}),
deliverRequested: async (request) => await this.deliverRequested(request),
finalizeResolved: async ({ resolved, entries }) => {
await this.finalizeResolved(resolved, entries);
},
finalizeExpired: async ({ entries }) => {
await this.clearPending(entries);
},
});
}
shouldHandle(request: ExecApprovalRequest): boolean {
shouldHandle(request: ApprovalRequest): boolean {
return matchesFilters({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
@ -212,107 +236,72 @@ export class TelegramExecApprovalHandler {
}
async start(): Promise<void> {
if (this.started) {
return;
}
this.started = true;
if (!isHandlerConfigured({ cfg: this.opts.cfg, accountId: this.opts.accountId })) {
return;
}
this.gatewayClient = await createOperatorApprovalsGatewayClient({
config: this.opts.cfg,
gatewayUrl: this.opts.gatewayUrl,
clientDisplayName: `Telegram Exec Approvals (${this.opts.accountId})`,
onEvent: (evt) => this.handleGatewayEvent(evt),
onConnectError: (err) => {
log.error(`telegram exec approvals: connect error: ${err.message}`);
},
});
this.gatewayClient.start();
await this.runtime.start();
}
async stop(): Promise<void> {
if (!this.started) {
return;
}
this.started = false;
for (const pending of this.pending.values()) {
clearTimeout(pending.timeoutId);
}
this.pending.clear();
this.gatewayClient?.stop();
this.gatewayClient = null;
await this.runtime.stop();
}
async handleRequested(request: ExecApprovalRequest): Promise<void> {
if (!this.shouldHandle(request)) {
return;
}
async handleRequested(request: ApprovalRequest): Promise<void> {
await this.runtime.handleRequested(request);
}
const targetMode = resolveTelegramExecApprovalTarget({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
const targets: TelegramApprovalTarget[] = [];
const sourceTarget = resolveTelegramSourceTarget({
private async deliverRequested(request: ApprovalRequest): Promise<PendingMessage[]> {
const approvalKind: ApprovalKind = request.id.startsWith("plugin:") ? "plugin" : "exec";
const deliveryPlan = await resolveChannelNativeApprovalDeliveryPlan({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
approvalKind,
request,
adapter: telegramNativeApprovalAdapter.native,
});
let fallbackToDm = false;
if (targetMode === "channel" || targetMode === "both") {
if (sourceTarget) {
targets.push(sourceTarget);
} else {
fallbackToDm = true;
}
}
if (targetMode === "dm" || targetMode === "both" || fallbackToDm) {
for (const approver of getTelegramExecApprovalApprovers({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
})) {
targets.push({ to: approver });
}
if (deliveryPlan.targets.length === 0) {
return [];
}
const resolvedTargets = dedupeTargets(targets);
if (resolvedTargets.length === 0) {
return;
}
const payloadParams: ExecApprovalPendingReplyParams = {
approvalId: request.id,
approvalSlug: request.id.slice(0, 8),
approvalCommandId: request.id,
command: resolveExecApprovalCommandDisplay(request.request).commandText,
cwd: request.request.cwd ?? undefined,
host: request.request.host === "node" ? "node" : "gateway",
nodeId: request.request.nodeId ?? undefined,
expiresAtMs: request.expiresAtMs,
nowMs: this.nowMs(),
};
const payload = buildExecApprovalPendingReplyPayload(payloadParams);
const buttons = buildTelegramExecApprovalButtons(request.id);
const payload =
approvalKind === "plugin"
? buildPluginApprovalPendingReplyPayload({
request: request as PluginApprovalRequest,
nowMs: this.nowMs(),
})
: buildExecApprovalPendingReplyPayload({
approvalId: request.id,
approvalSlug: request.id.slice(0, 8),
approvalCommandId: request.id,
command: resolveExecApprovalCommandDisplay((request as ExecApprovalRequest).request)
.commandText,
cwd: (request as ExecApprovalRequest).request.cwd ?? undefined,
host: (request as ExecApprovalRequest).request.host === "node" ? "node" : "gateway",
nodeId: (request as ExecApprovalRequest).request.nodeId ?? undefined,
expiresAtMs: request.expiresAtMs,
nowMs: this.nowMs(),
} satisfies ExecApprovalPendingReplyParams);
const buttons = resolveTelegramInlineButtons({
interactive: payload.interactive,
});
const sentMessages: PendingMessage[] = [];
for (const target of resolvedTargets) {
for (const target of deliveryPlan.targets) {
try {
await this.sendTyping(target.to, {
await this.sendTyping(target.target.to, {
cfg: this.opts.cfg,
token: this.opts.token,
accountId: this.opts.accountId,
...(typeof target.threadId === "number" ? { messageThreadId: target.threadId } : {}),
...(typeof target.target.threadId === "number"
? { messageThreadId: target.target.threadId }
: {}),
}).catch(() => {});
const result = await this.sendMessage(target.to, payload.text ?? "", {
const result = await this.sendMessage(target.target.to, payload.text ?? "", {
cfg: this.opts.cfg,
token: this.opts.token,
accountId: this.opts.accountId,
buttons,
...(typeof target.threadId === "number" ? { messageThreadId: target.threadId } : {}),
...(typeof target.target.threadId === "number"
? { messageThreadId: target.target.threadId }
: {}),
});
sentMessages.push({
chatId: result.chatId,
@ -322,33 +311,23 @@ export class TelegramExecApprovalHandler {
log.error(`telegram exec approvals: failed to send request ${request.id}: ${String(err)}`);
}
}
if (sentMessages.length === 0) {
return;
}
const timeoutMs = Math.max(0, request.expiresAtMs - this.nowMs());
const timeoutId = setTimeout(() => {
void this.handleResolved({ id: request.id, decision: "deny", ts: Date.now() });
}, timeoutMs);
timeoutId.unref?.();
this.pending.set(request.id, {
timeoutId,
messages: sentMessages,
});
return sentMessages;
}
async handleResolved(resolved: ExecApprovalResolved): Promise<void> {
const pending = this.pending.get(resolved.id);
if (!pending) {
return;
}
clearTimeout(pending.timeoutId);
this.pending.delete(resolved.id);
async handleResolved(resolved: ApprovalResolved): Promise<void> {
await this.runtime.handleResolved(resolved);
}
private async finalizeResolved(
_resolved: ApprovalResolved,
messages: PendingMessage[],
): Promise<void> {
await this.clearPending(messages);
}
private async clearPending(messages: PendingMessage[]): Promise<void> {
await Promise.allSettled(
pending.messages.map(async (message) => {
messages.map(async (message) => {
await this.editReplyMarkup(message.chatId, message.messageId, [], {
cfg: this.opts.cfg,
token: this.opts.token,
@ -357,14 +336,4 @@ export class TelegramExecApprovalHandler {
}),
);
}
private handleGatewayEvent(evt: EventFrame): void {
if (evt.event === "exec.approval.requested") {
void this.handleRequested(evt.payload as ExecApprovalRequest);
return;
}
if (evt.event === "exec.approval.resolved") {
void this.handleResolved(evt.payload as ExecApprovalResolved);
}
}
}

View File

@ -1,3 +1,7 @@
import {
isTelegramExecApprovalAuthorizedSender,
isTelegramExecApprovalClientEnabled,
} from "../../../extensions/telegram/api.js";
import { callGateway } from "../../gateway/call.js";
import { ErrorCodes } from "../../gateway/protocol/index.js";
import { logVerbose } from "../../globals.js";
@ -70,6 +74,17 @@ function buildResolvedByLabel(params: Parameters<CommandHandler>[0]): string {
return `${channel}:${sender}`;
}
function isAuthorizedTelegramExecSender(params: Parameters<CommandHandler>[0]): boolean {
if (params.command.channel !== "telegram") {
return false;
}
return isTelegramExecApprovalAuthorizedSender({
cfg: params.cfg,
accountId: params.ctx.AccountId,
senderId: params.command.senderId,
});
}
function readErrorCode(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value : null;
}
@ -112,29 +127,55 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
if (!parsed) {
return null;
}
if (!params.command.isAuthorizedSender) {
if (!parsed.ok) {
return { shouldContinue: false, reply: { text: parsed.error } };
}
const isPluginId = parsed.id.startsWith("plugin:");
const telegramExecAuthorizedSender = isAuthorizedTelegramExecSender(params);
const execApprovalAuthorization = resolveApprovalCommandAuthorization({
cfg: params.cfg,
channel: params.command.channel,
accountId: params.ctx.AccountId,
senderId: params.command.senderId,
kind: "exec",
});
const pluginApprovalAuthorization = resolveApprovalCommandAuthorization({
cfg: params.cfg,
channel: params.command.channel,
accountId: params.ctx.AccountId,
senderId: params.command.senderId,
kind: "plugin",
});
const hasExplicitApprovalAuthorization =
(execApprovalAuthorization.explicit && execApprovalAuthorization.authorized) ||
(pluginApprovalAuthorization.explicit && pluginApprovalAuthorization.authorized);
if (!params.command.isAuthorizedSender && !hasExplicitApprovalAuthorization) {
logVerbose(
`Ignoring /approve from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
if (!parsed.ok) {
return { shouldContinue: false, reply: { text: parsed.error } };
if (
params.command.channel === "telegram" &&
!isPluginId &&
!telegramExecAuthorizedSender &&
!isTelegramExecApprovalClientEnabled({ cfg: params.cfg, accountId: params.ctx.AccountId })
) {
return {
shouldContinue: false,
reply: { text: "❌ Telegram exec approvals are not enabled for this bot account." },
};
}
const isPluginId = parsed.id.startsWith("plugin:");
const approvalAuthorization = resolveApprovalCommandAuthorization({
cfg: params.cfg,
channel: params.command.channel,
accountId: params.ctx.AccountId,
senderId: params.command.senderId,
kind: isPluginId ? "plugin" : "exec",
});
if (!approvalAuthorization.authorized) {
if (isPluginId && !pluginApprovalAuthorization.authorized) {
return {
shouldContinue: false,
reply: {
text: approvalAuthorization.reason ?? "❌ You are not authorized to approve this request.",
text:
pluginApprovalAuthorization.reason ??
"❌ You are not authorized to approve this request.",
},
};
}
@ -160,7 +201,7 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
};
// Plugin approval IDs are kind-prefixed (`plugin:<uuid>`); route directly when detected.
// Unprefixed IDs try exec first, then fall back to plugin for backward compat.
// Unprefixed IDs try the authorized path first, then fall back for backward compat.
if (isPluginId) {
try {
await callApprovalMethod("plugin.approval.resolve");
@ -170,19 +211,12 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
};
}
} else {
} else if (execApprovalAuthorization.authorized) {
try {
await callApprovalMethod("exec.approval.resolve");
} catch (err) {
if (isApprovalNotFoundError(err)) {
const pluginFallbackAuthorization = resolveApprovalCommandAuthorization({
cfg: params.cfg,
channel: params.command.channel,
accountId: params.ctx.AccountId,
senderId: params.command.senderId,
kind: "plugin",
});
if (!pluginFallbackAuthorization.authorized) {
if (!pluginApprovalAuthorization.authorized) {
return {
shouldContinue: false,
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
@ -203,6 +237,29 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
};
}
}
} else if (pluginApprovalAuthorization.authorized) {
try {
await callApprovalMethod("plugin.approval.resolve");
} catch (err) {
if (isApprovalNotFoundError(err)) {
return {
shouldContinue: false,
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
};
}
return {
shouldContinue: false,
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
};
}
} else {
return {
shouldContinue: false,
reply: {
text:
execApprovalAuthorization.reason ?? "❌ You are not authorized to approve this request.",
},
};
}
return {

File diff suppressed because it is too large Load Diff

View File

@ -514,6 +514,48 @@ export type ChannelApprovalDeliveryAdapter = {
}) => boolean;
};
export type ChannelApprovalKind = "exec" | "plugin";
export type ChannelApprovalNativeSurface = "origin" | "approver-dm";
export type ChannelApprovalNativeTarget = {
to: string;
threadId?: string | number | null;
};
export type ChannelApprovalNativeDeliveryPreference = ChannelApprovalNativeSurface | "both";
export type ChannelApprovalNativeRequest = ExecApprovalRequest | PluginApprovalRequest;
export type ChannelApprovalNativeDeliveryCapabilities = {
enabled: boolean;
preferredSurface: ChannelApprovalNativeDeliveryPreference;
supportsOriginSurface: boolean;
supportsApproverDmSurface: boolean;
notifyOriginWhenDmOnly?: boolean;
};
export type ChannelApprovalNativeAdapter = {
describeDeliveryCapabilities: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ChannelApprovalKind;
request: ChannelApprovalNativeRequest;
}) => ChannelApprovalNativeDeliveryCapabilities;
resolveOriginTarget?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ChannelApprovalKind;
request: ChannelApprovalNativeRequest;
}) => ChannelApprovalNativeTarget | null | Promise<ChannelApprovalNativeTarget | null>;
resolveApproverDmTargets?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ChannelApprovalKind;
request: ChannelApprovalNativeRequest;
}) => ChannelApprovalNativeTarget[] | Promise<ChannelApprovalNativeTarget[]>;
};
export type ChannelApprovalRenderAdapter = {
exec?: {
buildPendingPayload?: (params: {
@ -546,6 +588,7 @@ export type ChannelApprovalRenderAdapter = {
export type ChannelApprovalAdapter = {
delivery?: ChannelApprovalDeliveryAdapter;
render?: ChannelApprovalRenderAdapter;
native?: ChannelApprovalNativeAdapter;
};
export type ChannelAllowlistAdapter = {

View File

@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import type { ChannelApprovalNativeAdapter } from "../channels/plugins/types.adapters.js";
import { resolveChannelNativeApprovalDeliveryPlan } from "./approval-native-delivery.js";
const execRequest = {
id: "approval-1",
request: {
command: "uname -a",
},
createdAtMs: 0,
expiresAtMs: 120_000,
};
describe("resolveChannelNativeApprovalDeliveryPlan", () => {
it("prefers the origin surface when configured and available", async () => {
const adapter: ChannelApprovalNativeAdapter = {
describeDeliveryCapabilities: () => ({
enabled: true,
preferredSurface: "origin",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
}),
resolveOriginTarget: async () => ({ to: "origin-chat", threadId: "42" }),
resolveApproverDmTargets: async () => [{ to: "approver-1" }],
};
const plan = await resolveChannelNativeApprovalDeliveryPlan({
cfg: {} as never,
approvalKind: "exec",
request: execRequest,
adapter,
});
expect(plan.notifyOriginWhenDmOnly).toBe(false);
expect(plan.targets).toEqual([
{
surface: "origin",
target: { to: "origin-chat", threadId: "42" },
reason: "preferred",
},
]);
});
it("falls back to approver DMs when origin delivery is unavailable", async () => {
const adapter: ChannelApprovalNativeAdapter = {
describeDeliveryCapabilities: () => ({
enabled: true,
preferredSurface: "origin",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
}),
resolveOriginTarget: async () => null,
resolveApproverDmTargets: async () => [{ to: "approver-1" }, { to: "approver-2" }],
};
const plan = await resolveChannelNativeApprovalDeliveryPlan({
cfg: {} as never,
approvalKind: "exec",
request: execRequest,
adapter,
});
expect(plan.targets).toEqual([
{
surface: "approver-dm",
target: { to: "approver-1" },
reason: "fallback",
},
{
surface: "approver-dm",
target: { to: "approver-2" },
reason: "fallback",
},
]);
});
it("requests an origin redirect notice when DM-only delivery has an origin context", async () => {
const adapter: ChannelApprovalNativeAdapter = {
describeDeliveryCapabilities: () => ({
enabled: true,
preferredSurface: "approver-dm",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
}),
resolveOriginTarget: async () => ({ to: "origin-chat" }),
resolveApproverDmTargets: async () => [{ to: "approver-1" }],
};
const plan = await resolveChannelNativeApprovalDeliveryPlan({
cfg: {} as never,
approvalKind: "plugin",
request: {
...execRequest,
id: "plugin:approval-1",
request: {
title: "Plugin approval",
description: "Needs access",
},
},
adapter,
});
expect(plan.originTarget).toEqual({ to: "origin-chat" });
expect(plan.notifyOriginWhenDmOnly).toBe(true);
expect(plan.targets).toEqual([
{
surface: "approver-dm",
target: { to: "approver-1" },
reason: "preferred",
},
]);
});
it("dedupes duplicate origin and DM targets when both surfaces converge", async () => {
const adapter: ChannelApprovalNativeAdapter = {
describeDeliveryCapabilities: () => ({
enabled: true,
preferredSurface: "both",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
}),
resolveOriginTarget: async () => ({ to: "shared-chat" }),
resolveApproverDmTargets: async () => [{ to: "shared-chat" }, { to: "approver-2" }],
};
const plan = await resolveChannelNativeApprovalDeliveryPlan({
cfg: {} as never,
approvalKind: "exec",
request: execRequest,
adapter,
});
expect(plan.targets).toEqual([
{
surface: "origin",
target: { to: "shared-chat" },
reason: "preferred",
},
{
surface: "approver-dm",
target: { to: "approver-2" },
reason: "preferred",
},
]);
});
});

View File

@ -0,0 +1,134 @@
import type {
ChannelApprovalKind,
ChannelApprovalNativeAdapter,
ChannelApprovalNativeSurface,
ChannelApprovalNativeTarget,
} from "../channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../config/config.js";
import type { ExecApprovalRequest } from "./exec-approvals.js";
import type { PluginApprovalRequest } from "./plugin-approvals.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
export type ChannelApprovalNativePlannedTarget = {
surface: ChannelApprovalNativeSurface;
target: ChannelApprovalNativeTarget;
reason: "preferred" | "fallback";
};
export type ChannelApprovalNativeDeliveryPlan = {
targets: ChannelApprovalNativePlannedTarget[];
originTarget: ChannelApprovalNativeTarget | null;
notifyOriginWhenDmOnly: boolean;
};
function buildTargetKey(target: ChannelApprovalNativeTarget): string {
return `${target.to}:${target.threadId ?? ""}`;
}
function dedupeTargets(
targets: ChannelApprovalNativePlannedTarget[],
): ChannelApprovalNativePlannedTarget[] {
const seen = new Set<string>();
const deduped: ChannelApprovalNativePlannedTarget[] = [];
for (const target of targets) {
const key = buildTargetKey(target.target);
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(target);
}
return deduped;
}
export async function resolveChannelNativeApprovalDeliveryPlan(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ChannelApprovalKind;
request: ApprovalRequest;
adapter?: ChannelApprovalNativeAdapter | null;
}): Promise<ChannelApprovalNativeDeliveryPlan> {
const adapter = params.adapter;
if (!adapter) {
return {
targets: [],
originTarget: null,
notifyOriginWhenDmOnly: false,
};
}
const capabilities = adapter.describeDeliveryCapabilities({
cfg: params.cfg,
accountId: params.accountId,
approvalKind: params.approvalKind,
request: params.request,
});
if (!capabilities.enabled) {
return {
targets: [],
originTarget: null,
notifyOriginWhenDmOnly: false,
};
}
const originTarget =
capabilities.supportsOriginSurface && adapter.resolveOriginTarget
? ((await adapter.resolveOriginTarget({
cfg: params.cfg,
accountId: params.accountId,
approvalKind: params.approvalKind,
request: params.request,
})) ?? null)
: null;
const approverDmTargets =
capabilities.supportsApproverDmSurface && adapter.resolveApproverDmTargets
? await adapter.resolveApproverDmTargets({
cfg: params.cfg,
accountId: params.accountId,
approvalKind: params.approvalKind,
request: params.request,
})
: [];
const plannedTargets: ChannelApprovalNativePlannedTarget[] = [];
const preferOrigin =
capabilities.preferredSurface === "origin" || capabilities.preferredSurface === "both";
const preferApproverDm =
capabilities.preferredSurface === "approver-dm" || capabilities.preferredSurface === "both";
if (preferOrigin && originTarget) {
plannedTargets.push({
surface: "origin",
target: originTarget,
reason: "preferred",
});
}
if (preferApproverDm) {
for (const target of approverDmTargets) {
plannedTargets.push({
surface: "approver-dm",
target,
reason: "preferred",
});
}
} else if (!originTarget) {
for (const target of approverDmTargets) {
plannedTargets.push({
surface: "approver-dm",
target,
reason: "fallback",
});
}
}
return {
targets: dedupeTargets(plannedTargets),
originTarget,
notifyOriginWhenDmOnly:
capabilities.preferredSurface === "approver-dm" &&
capabilities.notifyOriginWhenDmOnly === true &&
originTarget !== null,
};
}

View File

@ -21,7 +21,7 @@ describe("resolveApprovalCommandAuthorization", () => {
senderId: "U123",
kind: "exec",
}),
).toEqual({ authorized: true });
).toEqual({ authorized: true, explicit: false });
});
it("delegates to the channel approval override when present", () => {
@ -47,7 +47,7 @@ describe("resolveApprovalCommandAuthorization", () => {
senderId: "123",
kind: "exec",
}),
).toEqual({ authorized: true });
).toEqual({ authorized: true, explicit: true });
expect(
resolveApprovalCommandAuthorization({
@ -57,6 +57,25 @@ describe("resolveApprovalCommandAuthorization", () => {
senderId: "123",
kind: "plugin",
}),
).toEqual({ authorized: false, reason: "plugin denied" });
).toEqual({ authorized: false, reason: "plugin denied", explicit: true });
});
it("keeps disabled approval availability implicit even when same-chat auth returns allow", () => {
getChannelPluginMock.mockReturnValue({
auth: {
authorizeActorAction: () => ({ authorized: true }),
getActionAvailabilityState: () => ({ kind: "disabled" }),
},
});
expect(
resolveApprovalCommandAuthorization({
cfg: {} as never,
channel: "slack",
accountId: "work",
senderId: "U123",
kind: "exec",
}),
).toEqual({ authorized: true, explicit: false });
});
});

View File

@ -2,24 +2,42 @@ import { getChannelPlugin } from "../channels/plugins/index.js";
import type { OpenClawConfig } from "../config/config.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
export type ApprovalCommandAuthorization = {
authorized: boolean;
reason?: string;
explicit: boolean;
};
export function resolveApprovalCommandAuthorization(params: {
cfg: OpenClawConfig;
channel?: string | null;
accountId?: string | null;
senderId?: string | null;
kind: "exec" | "plugin";
}): { authorized: boolean; reason?: string } {
}): ApprovalCommandAuthorization {
const channel = normalizeMessageChannel(params.channel);
if (!channel) {
return { authorized: true };
return { authorized: true, explicit: false };
}
return (
getChannelPlugin(channel)?.auth?.authorizeActorAction?.({
cfg: params.cfg,
accountId: params.accountId,
senderId: params.senderId,
action: "approve",
approvalKind: params.kind,
}) ?? { authorized: true }
);
const channelPlugin = getChannelPlugin(channel);
const resolved = channelPlugin?.auth?.authorizeActorAction?.({
cfg: params.cfg,
accountId: params.accountId,
senderId: params.senderId,
action: "approve",
approvalKind: params.kind,
});
if (!resolved) {
return { authorized: true, explicit: false };
}
const availability = channelPlugin?.auth?.getActionAvailabilityState?.({
cfg: params.cfg,
accountId: params.accountId,
action: "approve",
});
return {
authorized: resolved.authorized,
reason: resolved.reason,
explicit: resolved.authorized ? availability?.kind !== "disabled" : true,
};
}

View File

@ -0,0 +1,437 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayClient } from "../gateway/client.js";
import type { PluginApprovalRequest, PluginApprovalResolved } from "./plugin-approvals.js";
const mockGatewayClientStarts = vi.hoisted(() => vi.fn());
const mockGatewayClientStops = vi.hoisted(() => vi.fn());
const mockGatewayClientRequests = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
const mockCreateOperatorApprovalsGatewayClient = vi.hoisted(() => vi.fn());
const loggerMocks = vi.hoisted(() => ({
debug: vi.fn(),
error: vi.fn(),
}));
vi.mock("../gateway/operator-approvals-client.js", () => ({
createOperatorApprovalsGatewayClient: mockCreateOperatorApprovalsGatewayClient,
}));
vi.mock("../logging/subsystem.js", () => ({
createSubsystemLogger: () => loggerMocks,
}));
let createExecApprovalChannelRuntime: typeof import("./exec-approval-channel-runtime.js").createExecApprovalChannelRuntime;
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
beforeEach(() => {
mockGatewayClientStarts.mockReset();
mockGatewayClientStops.mockReset();
mockGatewayClientRequests.mockReset();
mockGatewayClientRequests.mockResolvedValue({ ok: true });
loggerMocks.debug.mockReset();
loggerMocks.error.mockReset();
mockCreateOperatorApprovalsGatewayClient.mockReset().mockImplementation(async () => ({
start: mockGatewayClientStarts,
stop: mockGatewayClientStops,
request: mockGatewayClientRequests,
}));
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
beforeEach(async () => {
vi.resetModules();
({ createExecApprovalChannelRuntime } = await import("./exec-approval-channel-runtime.js"));
});
describe("createExecApprovalChannelRuntime", () => {
it("does not connect when the adapter is not configured", async () => {
const runtime = createExecApprovalChannelRuntime({
label: "test/exec-approvals",
clientDisplayName: "Test Exec Approvals",
cfg: {} as never,
isConfigured: () => false,
shouldHandle: () => true,
deliverRequested: async () => [],
finalizeResolved: async () => undefined,
});
await runtime.start();
expect(mockCreateOperatorApprovalsGatewayClient).not.toHaveBeenCalled();
});
it("tracks pending requests and only expires the matching approval id", async () => {
vi.useFakeTimers();
const finalizedExpired = vi.fn(async () => undefined);
const finalizedResolved = vi.fn(async () => undefined);
const runtime = createExecApprovalChannelRuntime({
label: "test/exec-approvals",
clientDisplayName: "Test Exec Approvals",
cfg: {} as never,
nowMs: () => 1000,
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested: async (request) => [{ id: request.id }],
finalizeResolved: finalizedResolved,
finalizeExpired: finalizedExpired,
});
await runtime.handleRequested({
id: "abc",
request: {
command: "echo abc",
},
createdAtMs: 1000,
expiresAtMs: 2000,
});
await runtime.handleRequested({
id: "xyz",
request: {
command: "echo xyz",
},
createdAtMs: 1000,
expiresAtMs: 2000,
});
await runtime.handleExpired("abc");
expect(finalizedExpired).toHaveBeenCalledTimes(1);
expect(finalizedExpired).toHaveBeenCalledWith({
request: expect.objectContaining({ id: "abc" }),
entries: [{ id: "abc" }],
});
expect(finalizedResolved).not.toHaveBeenCalled();
await runtime.handleResolved({
id: "xyz",
decision: "allow-once",
ts: 1500,
});
expect(finalizedResolved).toHaveBeenCalledTimes(1);
expect(finalizedResolved).toHaveBeenCalledWith({
request: expect.objectContaining({ id: "xyz" }),
resolved: expect.objectContaining({ id: "xyz", decision: "allow-once" }),
entries: [{ id: "xyz" }],
});
});
it("finalizes approvals that resolve while delivery is still in flight", async () => {
const pendingDelivery = createDeferred<Array<{ id: string }>>();
const finalizeResolved = vi.fn(async () => undefined);
const runtime = createExecApprovalChannelRuntime<
{ id: string },
PluginApprovalRequest,
PluginApprovalResolved
>({
label: "test/plugin-approvals",
clientDisplayName: "Test Plugin Approvals",
cfg: {} as never,
eventKinds: ["plugin"],
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested: async () => pendingDelivery.promise,
finalizeResolved,
});
const requestPromise = runtime.handleRequested({
id: "plugin:abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
},
createdAtMs: 1000,
expiresAtMs: 2000,
});
await runtime.handleResolved({
id: "plugin:abc",
decision: "allow-once",
ts: 1500,
});
pendingDelivery.resolve([{ id: "plugin:abc" }]);
await requestPromise;
expect(finalizeResolved).toHaveBeenCalledWith({
request: expect.objectContaining({ id: "plugin:abc" }),
resolved: expect.objectContaining({ id: "plugin:abc", decision: "allow-once" }),
entries: [{ id: "plugin:abc" }],
});
});
it("routes gateway requests through the shared client", async () => {
const runtime = createExecApprovalChannelRuntime({
label: "test/exec-approvals",
clientDisplayName: "Test Exec Approvals",
cfg: {} as never,
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested: async () => [],
finalizeResolved: async () => undefined,
});
await runtime.start();
await runtime.request("exec.approval.resolve", { id: "abc", decision: "deny" });
expect(mockGatewayClientStarts).toHaveBeenCalledTimes(1);
expect(mockGatewayClientRequests).toHaveBeenCalledWith("exec.approval.resolve", {
id: "abc",
decision: "deny",
});
});
it("can retry start after gateway client creation fails", async () => {
const boom = new Error("boom");
mockCreateOperatorApprovalsGatewayClient.mockRejectedValueOnce(boom).mockResolvedValueOnce({
start: mockGatewayClientStarts,
stop: mockGatewayClientStops,
request: mockGatewayClientRequests,
});
const runtime = createExecApprovalChannelRuntime({
label: "test/exec-approvals",
clientDisplayName: "Test Exec Approvals",
cfg: {} as never,
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested: async () => [],
finalizeResolved: async () => undefined,
});
await expect(runtime.start()).rejects.toThrow("boom");
await runtime.start();
expect(mockCreateOperatorApprovalsGatewayClient).toHaveBeenCalledTimes(2);
expect(mockGatewayClientStarts).toHaveBeenCalledTimes(1);
});
it("does not leave a gateway client running when stop wins the startup race", async () => {
const pendingClient = createDeferred<GatewayClient>();
mockCreateOperatorApprovalsGatewayClient.mockReturnValueOnce(pendingClient.promise);
const runtime = createExecApprovalChannelRuntime({
label: "test/exec-approvals",
clientDisplayName: "Test Exec Approvals",
cfg: {} as never,
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested: async () => [],
finalizeResolved: async () => undefined,
});
const startPromise = runtime.start();
const stopPromise = runtime.stop();
pendingClient.resolve({
start: mockGatewayClientStarts,
stop: mockGatewayClientStops,
request: mockGatewayClientRequests as GatewayClient["request"],
} as unknown as GatewayClient);
await startPromise;
await stopPromise;
expect(mockGatewayClientStarts).not.toHaveBeenCalled();
expect(mockGatewayClientStops).toHaveBeenCalledTimes(1);
await expect(runtime.request("exec.approval.resolve", { id: "abc" })).rejects.toThrow(
"gateway client not connected",
);
});
it("logs async request handling failures from gateway events", async () => {
const runtime = createExecApprovalChannelRuntime<
{ id: string },
PluginApprovalRequest,
PluginApprovalResolved
>({
label: "test/plugin-approvals",
clientDisplayName: "Test Plugin Approvals",
cfg: {} as never,
eventKinds: ["plugin"],
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested: async () => {
throw new Error("deliver failed");
},
finalizeResolved: async () => undefined,
});
await runtime.start();
const clientParams = mockCreateOperatorApprovalsGatewayClient.mock.calls[0]?.[0] as
| { onEvent?: (evt: { event: string; payload: unknown }) => void }
| undefined;
clientParams?.onEvent?.({
event: "plugin.approval.requested",
payload: {
id: "plugin:abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
},
createdAtMs: 1000,
expiresAtMs: 2000,
},
});
await vi.waitFor(() => {
expect(loggerMocks.error).toHaveBeenCalledWith(
"error handling approval request: deliver failed",
);
});
});
it("logs async expiration handling failures", async () => {
vi.useFakeTimers();
const runtime = createExecApprovalChannelRuntime<
{ id: string },
PluginApprovalRequest,
PluginApprovalResolved
>({
label: "test/plugin-approvals",
clientDisplayName: "Test Plugin Approvals",
cfg: {} as never,
nowMs: () => 1000,
eventKinds: ["plugin"],
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested: async (request) => [{ id: request.id }],
finalizeResolved: async () => undefined,
finalizeExpired: async () => {
throw new Error("expire failed");
},
});
await runtime.handleRequested({
id: "plugin:abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
},
createdAtMs: 1000,
expiresAtMs: 1001,
});
await vi.advanceTimersByTimeAsync(1);
expect(loggerMocks.error).toHaveBeenCalledWith(
"error handling approval expiration: expire failed",
);
});
it("subscribes to plugin approval events when requested", async () => {
const deliverRequested = vi.fn(async (request) => [{ id: request.id }]);
const finalizeResolved = vi.fn(async () => undefined);
const runtime = createExecApprovalChannelRuntime<
{ id: string },
PluginApprovalRequest,
PluginApprovalResolved
>({
label: "test/plugin-approvals",
clientDisplayName: "Test Plugin Approvals",
cfg: {} as never,
eventKinds: ["plugin"],
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested,
finalizeResolved,
});
await runtime.start();
const clientParams = mockCreateOperatorApprovalsGatewayClient.mock.calls[0]?.[0] as
| { onEvent?: (evt: { event: string; payload: unknown }) => void }
| undefined;
expect(clientParams?.onEvent).toBeTypeOf("function");
clientParams?.onEvent?.({
event: "plugin.approval.requested",
payload: {
id: "plugin:abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
},
createdAtMs: 1000,
expiresAtMs: 2000,
},
});
await vi.waitFor(() => {
expect(deliverRequested).toHaveBeenCalledWith(
expect.objectContaining({
id: "plugin:abc",
}),
);
});
clientParams?.onEvent?.({
event: "plugin.approval.resolved",
payload: {
id: "plugin:abc",
decision: "allow-once",
ts: 1500,
},
});
await vi.waitFor(() => {
expect(finalizeResolved).toHaveBeenCalledWith({
request: expect.objectContaining({ id: "plugin:abc" }),
resolved: expect.objectContaining({ id: "plugin:abc", decision: "allow-once" }),
entries: [{ id: "plugin:abc" }],
});
});
});
it("clears pending state when delivery throws", async () => {
const deliverRequested = vi
.fn<() => Promise<Array<{ id: string }>>>()
.mockRejectedValueOnce(new Error("deliver failed"))
.mockResolvedValueOnce([{ id: "abc" }]);
const finalizeResolved = vi.fn(async () => undefined);
const runtime = createExecApprovalChannelRuntime({
label: "test/delivery-failure",
clientDisplayName: "Test Delivery Failure",
cfg: {} as never,
isConfigured: () => true,
shouldHandle: () => true,
deliverRequested,
finalizeResolved,
});
await expect(
runtime.handleRequested({
id: "abc",
request: {
command: "echo abc",
},
createdAtMs: 1000,
expiresAtMs: 2000,
}),
).rejects.toThrow("deliver failed");
await runtime.handleRequested({
id: "abc",
request: {
command: "echo abc",
},
createdAtMs: 1000,
expiresAtMs: 2000,
});
await runtime.handleResolved({
id: "abc",
decision: "allow-once",
ts: 1500,
});
expect(finalizeResolved).toHaveBeenCalledWith({
request: expect.objectContaining({ id: "abc" }),
resolved: expect.objectContaining({ id: "abc", decision: "allow-once" }),
entries: [{ id: "abc" }],
});
});
});

View File

@ -0,0 +1,285 @@
import type { OpenClawConfig } from "../config/config.js";
import type { GatewayClient } from "../gateway/client.js";
import { createOperatorApprovalsGatewayClient } from "../gateway/operator-approvals-client.js";
import type { EventFrame } from "../gateway/protocol/index.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { ExecApprovalRequest, ExecApprovalResolved } from "./exec-approvals.js";
import type { PluginApprovalRequest, PluginApprovalResolved } from "./plugin-approvals.js";
type ApprovalRequestEvent = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalResolvedEvent = ExecApprovalResolved | PluginApprovalResolved;
export type ExecApprovalChannelRuntimeEventKind = "exec" | "plugin";
type PendingApprovalEntry<
TPending,
TRequest extends ApprovalRequestEvent,
TResolved extends ApprovalResolvedEvent,
> = {
request: TRequest;
entries: TPending[];
timeoutId: NodeJS.Timeout | null;
delivering: boolean;
pendingResolution: TResolved | null;
};
export type ExecApprovalChannelRuntimeAdapter<
TPending,
TRequest extends ApprovalRequestEvent = ExecApprovalRequest,
TResolved extends ApprovalResolvedEvent = ExecApprovalResolved,
> = {
label: string;
clientDisplayName: string;
cfg: OpenClawConfig;
gatewayUrl?: string;
eventKinds?: readonly ExecApprovalChannelRuntimeEventKind[];
isConfigured: () => boolean;
shouldHandle: (request: TRequest) => boolean;
deliverRequested: (request: TRequest) => Promise<TPending[]>;
finalizeResolved: (params: {
request: TRequest;
resolved: TResolved;
entries: TPending[];
}) => Promise<void>;
finalizeExpired?: (params: {
request: TRequest;
entries: TPending[];
}) => Promise<void>;
nowMs?: () => number;
};
export type ExecApprovalChannelRuntime<
TRequest extends ApprovalRequestEvent = ExecApprovalRequest,
TResolved extends ApprovalResolvedEvent = ExecApprovalResolved,
> = {
start: () => Promise<void>;
stop: () => Promise<void>;
handleRequested: (request: TRequest) => Promise<void>;
handleResolved: (resolved: TResolved) => Promise<void>;
handleExpired: (approvalId: string) => Promise<void>;
request: <T = unknown>(method: string, params: Record<string, unknown>) => Promise<T>;
};
export function createExecApprovalChannelRuntime<
TPending,
TRequest extends ApprovalRequestEvent = ExecApprovalRequest,
TResolved extends ApprovalResolvedEvent = ExecApprovalResolved,
>(
adapter: ExecApprovalChannelRuntimeAdapter<TPending, TRequest, TResolved>,
): ExecApprovalChannelRuntime<TRequest, TResolved> {
const log = createSubsystemLogger(adapter.label);
const nowMs = adapter.nowMs ?? Date.now;
const eventKinds = new Set<ExecApprovalChannelRuntimeEventKind>(adapter.eventKinds ?? ["exec"]);
const pending = new Map<string, PendingApprovalEntry<TPending, TRequest, TResolved>>();
let gatewayClient: GatewayClient | null = null;
let started = false;
let shouldRun = false;
let startPromise: Promise<void> | null = null;
const spawn = (label: string, promise: Promise<void>): void => {
void promise.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
log.error(`${label}: ${message}`);
});
};
const clearPendingEntry = (
approvalId: string,
): PendingApprovalEntry<TPending, TRequest, TResolved> | null => {
const entry = pending.get(approvalId);
if (!entry) {
return null;
}
pending.delete(approvalId);
if (entry.timeoutId) {
clearTimeout(entry.timeoutId);
}
return entry;
};
const handleExpired = async (approvalId: string): Promise<void> => {
const entry = clearPendingEntry(approvalId);
if (!entry) {
return;
}
log.debug(`expired ${approvalId}`);
await adapter.finalizeExpired?.({
request: entry.request,
entries: entry.entries,
});
};
const handleRequested = async (request: TRequest): Promise<void> => {
if (!adapter.shouldHandle(request)) {
return;
}
log.debug(`received request ${request.id}`);
const existing = pending.get(request.id);
if (existing?.timeoutId) {
clearTimeout(existing.timeoutId);
}
const entry: PendingApprovalEntry<TPending, TRequest, TResolved> = {
request,
entries: [],
timeoutId: null,
delivering: true,
pendingResolution: null,
};
pending.set(request.id, entry);
let entries: TPending[];
try {
entries = await adapter.deliverRequested(request);
} catch (err) {
if (pending.get(request.id) === entry) {
clearPendingEntry(request.id);
}
throw err;
}
const current = pending.get(request.id);
if (current !== entry) {
return;
}
if (!entries.length) {
pending.delete(request.id);
return;
}
entry.entries = entries;
entry.delivering = false;
if (entry.pendingResolution) {
pending.delete(request.id);
log.debug(`resolved ${entry.pendingResolution.id} with ${entry.pendingResolution.decision}`);
await adapter.finalizeResolved({
request: entry.request,
resolved: entry.pendingResolution,
entries: entry.entries,
});
return;
}
const timeoutMs = Math.max(0, request.expiresAtMs - nowMs());
const timeoutId = setTimeout(() => {
spawn("error handling approval expiration", handleExpired(request.id));
}, timeoutMs);
timeoutId.unref?.();
entry.timeoutId = timeoutId;
};
const handleResolved = async (resolved: TResolved): Promise<void> => {
const entry = pending.get(resolved.id);
if (!entry) {
return;
}
if (entry.delivering) {
entry.pendingResolution = resolved;
return;
}
const finalizedEntry = clearPendingEntry(resolved.id);
if (!finalizedEntry) {
return;
}
log.debug(`resolved ${resolved.id} with ${resolved.decision}`);
await adapter.finalizeResolved({
request: finalizedEntry.request,
resolved,
entries: finalizedEntry.entries,
});
};
const handleGatewayEvent = (evt: EventFrame): void => {
if (evt.event === "exec.approval.requested" && eventKinds.has("exec")) {
spawn("error handling approval request", handleRequested(evt.payload as TRequest));
return;
}
if (evt.event === "plugin.approval.requested" && eventKinds.has("plugin")) {
spawn("error handling approval request", handleRequested(evt.payload as TRequest));
return;
}
if (evt.event === "exec.approval.resolved" && eventKinds.has("exec")) {
spawn("error handling approval resolved", handleResolved(evt.payload as TResolved));
return;
}
if (evt.event === "plugin.approval.resolved" && eventKinds.has("plugin")) {
spawn("error handling approval resolved", handleResolved(evt.payload as TResolved));
}
};
return {
async start(): Promise<void> {
if (started) {
return;
}
if (startPromise) {
await startPromise;
return;
}
shouldRun = true;
startPromise = (async () => {
if (!adapter.isConfigured()) {
log.debug("disabled");
return;
}
const client = await createOperatorApprovalsGatewayClient({
config: adapter.cfg,
gatewayUrl: adapter.gatewayUrl,
clientDisplayName: adapter.clientDisplayName,
onEvent: handleGatewayEvent,
onHelloOk: () => {
log.debug("connected to gateway");
},
onConnectError: (err) => {
log.error(`connect error: ${err.message}`);
},
onClose: (code, reason) => {
log.debug(`gateway closed: ${code} ${reason}`);
},
});
if (!shouldRun) {
client.stop();
return;
}
client.start();
gatewayClient = client;
started = true;
})().finally(() => {
startPromise = null;
});
await startPromise;
},
async stop(): Promise<void> {
shouldRun = false;
if (startPromise) {
await startPromise.catch(() => {});
}
if (!started && !gatewayClient) {
return;
}
started = false;
for (const entry of pending.values()) {
if (entry.timeoutId) {
clearTimeout(entry.timeoutId);
}
}
pending.clear();
gatewayClient?.stop();
gatewayClient = null;
log.debug("stopped");
},
handleRequested,
handleResolved,
handleExpired,
async request<T = unknown>(method: string, params: Record<string, unknown>): Promise<T> {
if (!gatewayClient) {
throw new Error(`${adapter.label}: gateway client not connected`);
}
return (await gatewayClient.request(method, params)) as T;
},
};
}

View File

@ -1,10 +1,14 @@
import { describe, expect, it } from "vitest";
import type { ReplyPayload } from "../auto-reply/types.js";
import {
buildExecApprovalActionDescriptors,
buildExecApprovalCommandText,
buildExecApprovalInteractiveReply,
buildExecApprovalPendingReplyPayload,
buildExecApprovalUnavailableReplyPayload,
getExecApprovalApproverDmNoticeText,
getExecApprovalReplyMetadata,
parseExecApprovalCommandText,
} from "./exec-approval-reply.js";
describe("exec approval reply helpers", () => {
@ -166,6 +170,77 @@ describe("exec approval reply helpers", () => {
expect(payload.text).toContain("Expires in: 30m");
});
it("builds shared exec approval action descriptors and interactive replies", () => {
expect(
buildExecApprovalActionDescriptors({
approvalCommandId: "req-1",
}),
).toEqual([
{
decision: "allow-once",
label: "Allow Once",
style: "success",
command: "/approve req-1 allow-once",
},
{
decision: "allow-always",
label: "Allow Always",
style: "primary",
command: "/approve req-1 always",
},
{
decision: "deny",
label: "Deny",
style: "danger",
command: "/approve req-1 deny",
},
]);
expect(
buildExecApprovalInteractiveReply({
approvalCommandId: "req-1",
}),
).toEqual({
blocks: [
{
type: "buttons",
buttons: [
{ label: "Allow Once", value: "/approve req-1 allow-once", style: "success" },
{ label: "Allow Always", value: "/approve req-1 always", style: "primary" },
{ label: "Deny", value: "/approve req-1 deny", style: "danger" },
],
},
],
});
});
it("builds and parses shared exec approval command text", () => {
expect(
buildExecApprovalCommandText({
approvalCommandId: "req-1",
decision: "allow-always",
}),
).toBe("/approve req-1 always");
expect(parseExecApprovalCommandText("/approve req-1 deny")).toEqual({
approvalId: "req-1",
decision: "deny",
});
expect(parseExecApprovalCommandText("/approve@clover req-1 allow-once")).toEqual({
approvalId: "req-1",
decision: "allow-once",
});
expect(parseExecApprovalCommandText(" /approve req-1 always")).toEqual({
approvalId: "req-1",
decision: "allow-always",
});
expect(parseExecApprovalCommandText("/approve req-1 allow-always")).toEqual({
approvalId: "req-1",
decision: "allow-always",
});
expect(parseExecApprovalCommandText("/approve req-1 maybe")).toBeNull();
});
it("builds unavailable payloads for approver DMs", () => {
expect(
buildExecApprovalUnavailableReplyPayload({

View File

@ -14,6 +14,13 @@ export type ExecApprovalReplyMetadata = {
allowedDecisions?: readonly ExecApprovalReplyDecision[];
};
export type ExecApprovalActionDescriptor = {
decision: ExecApprovalReplyDecision;
label: string;
style: NonNullable<InteractiveReplyButton["style"]>;
command: string;
};
export type ExecApprovalPendingReplyParams = {
warningText?: string;
approvalId: string;
@ -36,40 +43,71 @@ export type ExecApprovalUnavailableReplyParams = {
const DEFAULT_ALLOWED_DECISIONS = ["allow-once", "allow-always", "deny"] as const;
function buildApprovalDecisionCommandValue(params: {
approvalId: string;
export function buildExecApprovalCommandText(params: {
approvalCommandId: string;
decision: ExecApprovalReplyDecision;
}): string {
return `/approve ${params.approvalId} ${params.decision === "allow-always" ? "always" : params.decision}`;
return `/approve ${params.approvalCommandId} ${params.decision === "allow-always" ? "always" : params.decision}`;
}
export function buildExecApprovalActionDescriptors(params: {
approvalCommandId: string;
allowedDecisions?: readonly ExecApprovalReplyDecision[];
}): ExecApprovalActionDescriptor[] {
const approvalCommandId = params.approvalCommandId.trim();
if (!approvalCommandId) {
return [];
}
const allowedDecisions = params.allowedDecisions ?? DEFAULT_ALLOWED_DECISIONS;
const descriptors: ExecApprovalActionDescriptor[] = [];
if (allowedDecisions.includes("allow-once")) {
descriptors.push({
decision: "allow-once",
label: "Allow Once",
style: "success",
command: buildExecApprovalCommandText({
approvalCommandId,
decision: "allow-once",
}),
});
}
if (allowedDecisions.includes("allow-always")) {
descriptors.push({
decision: "allow-always",
label: "Allow Always",
style: "primary",
command: buildExecApprovalCommandText({
approvalCommandId,
decision: "allow-always",
}),
});
}
if (allowedDecisions.includes("deny")) {
descriptors.push({
decision: "deny",
label: "Deny",
style: "danger",
command: buildExecApprovalCommandText({
approvalCommandId,
decision: "deny",
}),
});
}
return descriptors;
}
function buildApprovalInteractiveButtons(
allowedDecisions: readonly ExecApprovalReplyDecision[],
approvalId: string,
): InteractiveReplyButton[] {
const buttons: InteractiveReplyButton[] = [];
if (allowedDecisions.includes("allow-once")) {
buttons.push({
label: "Allow Once",
value: buildApprovalDecisionCommandValue({ approvalId, decision: "allow-once" }),
style: "success",
});
}
if (allowedDecisions.includes("allow-always")) {
buttons.push({
label: "Allow Always",
value: buildApprovalDecisionCommandValue({ approvalId, decision: "allow-always" }),
style: "primary",
});
}
if (allowedDecisions.includes("deny")) {
buttons.push({
label: "Deny",
value: buildApprovalDecisionCommandValue({ approvalId, decision: "deny" }),
style: "danger",
});
}
return buttons;
return buildExecApprovalActionDescriptors({
approvalCommandId: approvalId,
allowedDecisions,
}).map((descriptor) => ({
label: descriptor.label,
value: descriptor.command,
style: descriptor.style,
}));
}
export function buildApprovalInteractiveReply(params: {
@ -83,10 +121,37 @@ export function buildApprovalInteractiveReply(params: {
return buttons.length > 0 ? { blocks: [{ type: "buttons", buttons }] } : undefined;
}
export function buildExecApprovalInteractiveReply(params: {
approvalCommandId: string;
allowedDecisions?: readonly ExecApprovalReplyDecision[];
}): InteractiveReply | undefined {
return buildApprovalInteractiveReply({
approvalId: params.approvalCommandId,
allowedDecisions: params.allowedDecisions,
});
}
export function getExecApprovalApproverDmNoticeText(): string {
return "Approval required. I sent approval DMs to the approvers for this account.";
}
export function parseExecApprovalCommandText(
raw: string,
): { approvalId: string; decision: ExecApprovalReplyDecision } | null {
const trimmed = raw.trim();
const match = trimmed.match(
/^\/approve(?:@[^\s]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+(allow-once|allow-always|always|deny)\b/i,
);
if (!match) {
return null;
}
const rawDecision = match[2].toLowerCase();
return {
approvalId: match[1],
decision: rawDecision === "always" ? "allow-always" : (rawDecision as ExecApprovalReplyDecision),
};
}
export function formatExecApprovalExpiresIn(expiresAtMs: number, nowMs: number): string {
const totalSeconds = Math.max(0, Math.round((expiresAtMs - nowMs) / 1000));
if (totalSeconds < 60) {

View File

@ -59,9 +59,22 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
isNativeDeliveryEnabled: ({ accountId }) => accountId !== "disabled",
resolveNativeDeliveryMode: ({ accountId }) =>
accountId === "channel-only" ? "channel" : "dm",
resolveOriginTarget: () => ({ to: "origin-chat" }),
resolveApproverDmTargets: () => [{ to: "approver-1" }],
});
const getActionAvailabilityState = adapter.auth.getActionAvailabilityState;
const hasConfiguredDmRoute = adapter.delivery.hasConfiguredDmRoute;
const nativeCapabilities = adapter.native?.describeDeliveryCapabilities({
cfg: {} as never,
accountId: "channel-only",
approvalKind: "exec",
request: {
id: "approval-1",
request: { command: "pwd" },
createdAtMs: 0,
expiresAtMs: 10_000,
},
});
expect(
getActionAvailabilityState({
@ -78,6 +91,13 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
}),
).toEqual({ kind: "disabled" });
expect(hasConfiguredDmRoute({ cfg: {} as never })).toBe(true);
expect(nativeCapabilities).toEqual({
enabled: true,
preferredSurface: "origin",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: false,
});
});
it("suppresses forwarding fallback only for matching native-delivery surfaces", () => {

View File

@ -1,8 +1,13 @@
import type { ExecApprovalRequest } from "../infra/exec-approvals.js";
import type { PluginApprovalRequest } from "../infra/plugin-approvals.js";
import type { OpenClawConfig } from "./config-runtime.js";
import { normalizeMessageChannel } from "./routing.js";
type ApprovalKind = "exec" | "plugin";
type NativeApprovalDeliveryMode = "dm" | "channel" | "both";
type NativeApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type NativeApprovalTarget = { to: string; threadId?: string | number | null };
type NativeApprovalSurface = "origin" | "approver-dm";
type ApprovalAdapterParams = {
cfg: OpenClawConfig;
@ -30,8 +35,25 @@ export function createApproverRestrictedNativeApprovalAdapter(params: {
}) => NativeApprovalDeliveryMode;
requireMatchingTurnSourceChannel?: boolean;
resolveSuppressionAccountId?: (params: DeliverySuppressionParams) => string | undefined;
resolveOriginTarget?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
request: NativeApprovalRequest;
}) => NativeApprovalTarget | null | Promise<NativeApprovalTarget | null>;
resolveApproverDmTargets?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
request: NativeApprovalRequest;
}) => NativeApprovalTarget[] | Promise<NativeApprovalTarget[]>;
notifyOriginWhenDmOnly?: boolean;
}) {
const pluginSenderAuth = params.isPluginAuthorizedSender ?? params.isExecAuthorizedSender;
const normalizePreferredSurface = (
mode: NativeApprovalDeliveryMode,
): NativeApprovalSurface | "both" =>
mode === "channel" ? "origin" : mode === "dm" ? "approver-dm" : "both";
return {
auth: {
@ -103,5 +125,31 @@ export function createApproverRestrictedNativeApprovalAdapter(params: {
return params.isNativeDeliveryEnabled({ cfg: input.cfg, accountId });
},
},
native:
params.resolveOriginTarget || params.resolveApproverDmTargets
? {
describeDeliveryCapabilities: ({
cfg,
accountId,
}: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
request: NativeApprovalRequest;
}) => ({
enabled:
params.hasApprovers({ cfg, accountId }) &&
params.isNativeDeliveryEnabled({ cfg, accountId }),
preferredSurface: normalizePreferredSurface(
params.resolveNativeDeliveryMode({ cfg, accountId }),
),
supportsOriginSurface: Boolean(params.resolveOriginTarget),
supportsApproverDmSurface: Boolean(params.resolveApproverDmTargets),
notifyOriginWhenDmOnly: params.notifyOriginWhenDmOnly ?? false,
}),
resolveOriginTarget: params.resolveOriginTarget,
resolveApproverDmTargets: params.resolveApproverDmTargets,
}
: undefined,
};
}

View File

@ -8,9 +8,11 @@ export * from "../infra/diagnostic-flags.js";
export * from "../infra/env.js";
export * from "../infra/errors.js";
export * from "../infra/exec-approval-command-display.ts";
export * from "../infra/exec-approval-channel-runtime.ts";
export * from "../infra/exec-approval-reply.ts";
export * from "../infra/exec-approval-session-target.ts";
export * from "../infra/exec-approvals.ts";
export * from "../infra/approval-native-delivery.ts";
export * from "../infra/plugin-approvals.ts";
export * from "../infra/fetch.js";
export * from "../infra/file-lock.js";