mirror of https://github.com/openclaw/openclaw.git
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:
parent
e7e15b92bd
commit
9ff57ac479
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" }]]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue