Compare commits

..

1 Commits

Author SHA1 Message Date
scoootscooob
9e1426b2a9 refactor(exec): centralize native approval delivery 2026-03-30 08:33:57 -07:00
17 changed files with 947 additions and 285 deletions

View File

@@ -109,7 +109,7 @@
"exportName": "ChannelConfiguredBindingConversationRef",
"kind": "type",
"source": {
"line": 607,
"line": 650,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -118,7 +118,7 @@
"exportName": "ChannelConfiguredBindingMatch",
"kind": "type",
"source": {
"line": 612,
"line": 655,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -127,7 +127,7 @@
"exportName": "ChannelConfiguredBindingProvider",
"kind": "type",
"source": {
"line": 628,
"line": 671,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1206,7 +1206,7 @@
"exportName": "ChannelCommandConversationContext",
"kind": "type",
"source": {
"line": 616,
"line": 659,
"path": "src/channels/plugins/types.adapters.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"
}
},

View File

@@ -10,9 +10,9 @@
{"declaration":"export type ChannelCapabilities = ChannelCapabilities;","entrypoint":"index","exportName":"ChannelCapabilities","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":232,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelConfigSchema = ChannelConfigSchema;","entrypoint":"index","exportName":"ChannelConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":69,"sourcePath":"src/channels/plugins/types.plugin.ts"}
{"declaration":"export type ChannelConfigUiHint = ChannelConfigUiHint;","entrypoint":"index","exportName":"ChannelConfigUiHint","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":38,"sourcePath":"src/channels/plugins/types.plugin.ts"}
{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"index","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":607,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"index","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":612,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"index","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":628,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"index","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":650,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"index","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":655,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"index","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":671,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext<ResolvedAccount>;","entrypoint":"index","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":268,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelId = ChannelId;","entrypoint":"index","exportName":"ChannelId","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":13,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"index","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":526,"sourcePath":"src/channels/plugins/types.core.ts"}
@@ -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"}
@@ -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"}

View File

@@ -0,0 +1,48 @@
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",
turnSourceChannel: "discord",
turnSourceTo: "channel: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",
sessionKey: "agent:main:discord:channel:987654321",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toEqual({ to: "987654321" });
});
});

View File

@@ -0,0 +1,150 @@
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 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,
},
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,
});
}
function resolveDiscordOriginTarget(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
}) {
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || "";
const turnSourceTo = normalizeDiscordOriginChannelId(params.request.request.turnSourceTo);
const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || "";
if (turnSourceChannel === "discord" && turnSourceTo) {
if (
params.accountId &&
turnSourceAccountId &&
normalizeAccountId(turnSourceAccountId) !== normalizeAccountId(params.accountId)
) {
return null;
}
return { to: turnSourceTo };
}
const sessionTarget = resolveRequestSessionTarget(params);
if (!sessionTarget || sessionTarget.channel !== "discord") {
const channelId = extractDiscordChannelId(params.request.request.sessionKey?.trim() || null);
return channelId ? { to: channelId } : null;
}
if (
params.accountId &&
sessionTarget.accountId &&
normalizeAccountId(sessionTarget.accountId) !== normalizeAccountId(params.accountId)
) {
return null;
}
const targetTo = normalizeDiscordOriginChannelId(sessionTarget.to);
return targetTo ? { to: targetTo } : null;
}
function resolveDiscordApproverDmTargets(params: {
cfg: OpenClawConfig;
accountId?: string | null;
configOverride?: DiscordExecApprovalConfig | null;
}) {
return getDiscordExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
configOverride: params.configOverride,
}).map((approver) => ({ to: String(approver) }));
}
export function createDiscordNativeApprovalAdapter(
configOverride?: DiscordExecApprovalConfig | null,
) {
return createApproverRestrictedNativeApprovalAdapter({
channel: "discord",
channelLabel: "Discord",
listAccountIds: listDiscordAccountIds,
hasApprovers: ({ cfg, accountId }) =>
getDiscordExecApprovalApprovers({ cfg, accountId, configOverride }).length > 0,
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
isDiscordExecApprovalApprover({ cfg, accountId, senderId, configOverride }),
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
isDiscordExecApprovalClientEnabled({ cfg, accountId, configOverride }),
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
configOverride?.target ??
resolveDiscordAccount({ cfg, accountId }).config.execApprovals?.target ??
"dm",
resolveOriginTarget: ({ cfg, accountId, request }) =>
resolveDiscordOriginTarget({ cfg, accountId, request }),
resolveApproverDmTargets: ({ cfg, accountId }) =>
resolveDiscordApproverDmTargets({ cfg, accountId, configOverride }),
notifyOriginWhenDmOnly: true,
});
}
export const discordNativeApprovalAdapter = createDiscordNativeApprovalAdapter();

View File

@@ -4,7 +4,6 @@ import {
createAccountScopedAllowlistNameResolver,
createNestedAllowlistOverrideResolver,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import { createApproverRestrictedNativeApprovalAdapter } from "openclaw/plugin-sdk/approval-runtime";
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
@@ -28,17 +27,13 @@ import {
resolveDiscordAccount,
type ResolvedDiscordAccount,
} from "./accounts.js";
import { discordNativeApprovalAdapter } from "./approval-native.js";
import { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./audit.js";
import {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
} from "./directory-config.js";
import {
getDiscordExecApprovalApprovers,
isDiscordExecApprovalApprover,
isDiscordExecApprovalClientEnabled,
shouldSuppressLocalDiscordExecApprovalPrompt,
} from "./exec-approvals.js";
import { shouldSuppressLocalDiscordExecApprovalPrompt } from "./exec-approvals.js";
import {
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
@@ -151,20 +146,6 @@ function buildDiscordCrossContextComponents(params: {
return [new DiscordUiContainer({ cfg: params.cfg, accountId: params.accountId, components })];
}
const discordNativeApprovalAdapter = createApproverRestrictedNativeApprovalAdapter({
channel: "discord",
channelLabel: "Discord",
listAccountIds: listDiscordAccountIds,
hasApprovers: ({ cfg, accountId }) =>
getDiscordExecApprovalApprovers({ cfg, accountId }).length > 0,
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
isDiscordExecApprovalApprover({ cfg, accountId, senderId }),
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
isDiscordExecApprovalClientEnabled({ cfg, accountId }),
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
resolveDiscordAccount({ cfg, accountId }).config.execApprovals?.target ?? "dm",
});
const resolveDiscordAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({
resolveRecord: (account: ResolvedDiscordAccount) => account.config.guilds,
outerLabel: (guildKey) => `guild ${guildKey}`,
@@ -347,6 +328,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
auth: discordNativeApprovalAdapter.auth,
approvals: {
delivery: discordNativeApprovalAdapter.delivery,
native: discordNativeApprovalAdapter.native,
},
directory: createChannelDirectoryAdapter({
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),

View File

@@ -1,6 +1,7 @@
import { getExecApprovalReplyMetadata } from "openclaw/plugin-sdk/approval-runtime";
import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { resolveDiscordAccount } from "./accounts.js";
import { parseDiscordTarget } from "./targets.js";
@@ -24,10 +25,11 @@ function normalizeDiscordApproverId(value: string): string | undefined {
export function getDiscordExecApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
configOverride?: DiscordExecApprovalConfig | null;
}): string[] {
const account = resolveDiscordAccount(params).config;
return resolveApprovalApprovers({
explicit: account.execApprovals?.approvers,
explicit: params.configOverride?.approvers ?? account.execApprovals?.approvers,
allowFrom: account.allowFrom,
extraAllowFrom: account.dm?.allowFrom,
defaultTo: account.defaultTo,
@@ -46,21 +48,34 @@ export function getDiscordExecApprovalApprovers(params: {
export function isDiscordExecApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
configOverride?: DiscordExecApprovalConfig | null;
}): boolean {
const config = resolveDiscordAccount(params).config.execApprovals;
return Boolean(config?.enabled && getDiscordExecApprovalApprovers(params).length > 0);
const config = params.configOverride ?? resolveDiscordAccount(params).config.execApprovals;
return Boolean(
config?.enabled &&
getDiscordExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
configOverride: params.configOverride,
}).length > 0,
);
}
export function isDiscordExecApprovalApprover(params: {
cfg: OpenClawConfig;
accountId?: string | null;
senderId?: string | null;
configOverride?: DiscordExecApprovalConfig | null;
}): boolean {
const senderId = params.senderId?.trim();
if (!senderId) {
return false;
}
return getDiscordExecApprovalApprovers(params).includes(senderId);
return getDiscordExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
configOverride: params.configOverride,
}).includes(senderId);
}
export function shouldSuppressLocalDiscordExecApprovalPrompt(params: {

View File

@@ -16,6 +16,7 @@ import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runti
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";
@@ -36,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 { 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,
@@ -51,16 +54,6 @@ type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved;
type ApprovalKind = "exec" | "plugin";
/** 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;
}
function buildDiscordApprovalDmRedirectNotice(): { content: string } {
return {
content: getExecApprovalApproverDmNoticeText(),
@@ -70,6 +63,7 @@ function buildDiscordApprovalDmRedirectNotice(): { content: string } {
type PendingApproval = {
discordMessageId: string;
discordChannelId: string;
timeoutId?: NodeJS.Timeout;
};
function resolveApprovalKindFromId(approvalId: string): ApprovalKind {
@@ -514,7 +508,11 @@ export class DiscordExecApprovalHandler {
constructor(opts: DiscordExecApprovalHandlerOpts) {
this.opts = opts;
this.runtime = createExecApprovalChannelRuntime<PendingApproval, ApprovalRequest, ApprovalResolved>({
this.runtime = createExecApprovalChannelRuntime<
PendingApproval,
ApprovalRequest,
ApprovalResolved
>({
label: "discord/exec-approvals",
clientDisplayName: "Discord Exec Approvals",
cfg: this.opts.cfg,
@@ -615,22 +613,22 @@ export class DiscordExecApprovalHandler {
});
const payload = buildExecApprovalPayload(container);
const body = 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 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 originatingChannelId =
target === "dm"
? extractDiscordChannelId(resolveApprovalSessionKey(request))
: null;
if (target === "dm" && originatingChannelId) {
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",
@@ -640,15 +638,12 @@ export class DiscordExecApprovalHandler {
}
}
// Send to originating channel if configured
if (sendToChannel) {
const sessionKey = resolveApprovalSessionKey(request);
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",
@@ -657,70 +652,55 @@ export class DiscordExecApprovalHandler {
if (message?.id) {
pendingEntries.push({
discordMessageId: message.id,
discordChannelId: channelId,
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");
}
continue;
}
}
// Send to approver DMs if configured (or as fallback when channel extraction fails)
if (sendToDm || fallbackToDm) {
const approvers = this.opts.config.approvers ?? [];
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 };
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;
}
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)}`);
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;

View File

@@ -0,0 +1,136 @@
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;
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 resolveTelegramOriginTarget(params: {
cfg: OpenClawConfig;
accountId: string;
request: ApprovalRequest;
}) {
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 resolveTelegramApproverDmTargets(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) {
return getTelegramExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
}).map((approver) => ({ to: approver }));
}
export const telegramNativeApprovalAdapter = createApproverRestrictedNativeApprovalAdapter({
channel: "telegram",
channelLabel: "Telegram",
listAccountIds: listTelegramAccountIds,
hasApprovers: ({ cfg, accountId }) =>
getTelegramExecApprovalApprovers({ cfg, accountId }).length > 0,
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
isTelegramExecApprovalAuthorizedSender({ cfg, accountId, senderId }),
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) =>
isTelegramExecApprovalApprover({ cfg, accountId, senderId }),
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
isTelegramExecApprovalClientEnabled({ cfg, accountId }),
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
resolveTelegramExecApprovalTarget({ cfg, accountId }),
requireMatchingTurnSourceChannel: true,
resolveSuppressionAccountId: ({ target, request }) =>
target.accountId?.trim() || request.request.turnSourceAccountId?.trim() || undefined,
resolveOriginTarget: ({ cfg, accountId, request }) =>
accountId
? resolveTelegramOriginTarget({
cfg,
accountId,
request,
})
: null,
resolveApproverDmTargets: ({ cfg, accountId }) =>
resolveTelegramApproverDmTargets({ cfg, accountId }),
});

View File

@@ -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";
@@ -423,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,
@@ -596,6 +577,7 @@ export const telegramPlugin = createChatChannelPlugin({
auth: telegramNativeApprovalAdapter.auth,
approvals: {
delivery: telegramNativeApprovalAdapter.delivery,
native: telegramNativeApprovalAdapter.native,
render: {
exec: {
buildPendingPayload: ({ request, nowMs }) =>

View File

@@ -17,6 +17,23 @@ const baseRequest = {
expiresAtMs: 61_000,
};
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) {
const sendTyping = vi.fn().mockResolvedValue({ ok: true });
const sendMessage = vi
@@ -154,4 +171,37 @@ 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",
}),
]),
]),
}),
);
});
});

View File

@@ -1,40 +1,44 @@
import { buildPluginApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
createExecApprovalChannelRuntime,
type ExecApprovalChannelRuntime,
resolveChannelNativeApprovalDeliveryPlan,
} from "openclaw/plugin-sdk/infra-runtime";
import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime";
import {
buildExecApprovalInteractiveReply,
buildExecApprovalPendingReplyPayload,
type ExecApprovalPendingReplyParams,
} from "openclaw/plugin-sdk/infra-runtime";
import { resolveExecApprovalSessionTarget } from "openclaw/plugin-sdk/infra-runtime";
import type { ExecApprovalRequest, ExecApprovalResolved } from "openclaw/plugin-sdk/infra-runtime";
import { normalizeAccountId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
import type {
ExecApprovalRequest,
ExecApprovalResolved,
PluginApprovalRequest,
PluginApprovalResolved,
} from "openclaw/plugin-sdk/infra-runtime";
import { parseAgentSessionKey } 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 { 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 TelegramApprovalTarget = {
to: string;
threadId?: number;
};
export type TelegramExecApprovalHandlerOpts = {
token: string;
accountId: string;
@@ -53,7 +57,7 @@ export type TelegramExecApprovalHandlerDeps = {
function matchesFilters(params: {
cfg: OpenClawConfig;
accountId: string;
request: ExecApprovalRequest;
request: ApprovalRequest;
}): boolean {
const config = resolveTelegramExecApprovalConfig({
cfg: params.cfg,
@@ -112,76 +116,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 readonly runtime: ExecApprovalChannelRuntime;
private readonly runtime: ExecApprovalChannelRuntime<ApprovalRequest, ApprovalResolved>;
private readonly nowMs: () => number;
private readonly sendTyping: typeof sendTypingTelegram;
private readonly sendMessage: typeof sendMessageTelegram;
@@ -195,11 +131,16 @@ export class TelegramExecApprovalHandler {
this.sendTyping = deps.sendTyping ?? sendTypingTelegram;
this.sendMessage = deps.sendMessage ?? sendMessageTelegram;
this.editReplyMarkup = deps.editReplyMarkup ?? editMessageReplyMarkupTelegram;
this.runtime = createExecApprovalChannelRuntime<PendingMessage>({
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 }),
@@ -219,7 +160,7 @@ export class TelegramExecApprovalHandler {
});
}
shouldHandle(request: ExecApprovalRequest): boolean {
shouldHandle(request: ApprovalRequest): boolean {
return matchesFilters({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
@@ -235,80 +176,65 @@ export class TelegramExecApprovalHandler {
await this.runtime.stop();
}
async handleRequested(request: ExecApprovalRequest): Promise<void> {
async handleRequested(request: ApprovalRequest): Promise<void> {
await this.runtime.handleRequested(request);
}
private async deliverRequested(request: ExecApprovalRequest): Promise<PendingMessage[]> {
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 });
}
}
const resolvedTargets = dedupeTargets(targets);
if (resolvedTargets.length === 0) {
if (deliveryPlan.targets.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),
interactive: buildExecApprovalInteractiveReply({
approvalCommandId: 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,
@@ -321,12 +247,12 @@ export class TelegramExecApprovalHandler {
return sentMessages;
}
async handleResolved(resolved: ExecApprovalResolved): Promise<void> {
async handleResolved(resolved: ApprovalResolved): Promise<void> {
await this.runtime.handleResolved(resolved);
}
private async finalizeResolved(
_resolved: ExecApprovalResolved,
_resolved: ApprovalResolved,
messages: PendingMessage[],
): Promise<void> {
await this.clearPending(messages);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ 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";