refactor: dedupe channel approval forwarding

This commit is contained in:
Vincent Koc
2026-05-29 00:48:37 +02:00
parent b0e9569ebd
commit 6d90e00fa3
9 changed files with 349 additions and 675 deletions

View File

@@ -1,23 +1,18 @@
import {
buildChannelApprovalExpiredText,
buildChannelApprovalResolvedText,
createChannelApprovalNativeRuntimeAdapter,
type ExpiredApprovalView,
type PendingApprovalView,
type ResolvedApprovalView,
resolvePreparedApprovalAccountId,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import { buildApprovalReactionPendingContent } from "openclaw/plugin-sdk/approval-reaction-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import {
buildApprovalResolvedReplyPayload,
buildPluginApprovalExpiredMessage,
buildPluginApprovalResolvedMessage,
type ExecApprovalRequest,
type ExecApprovalResolved,
type PluginApprovalRequest,
type PluginApprovalResolved,
} from "openclaw/plugin-sdk/approval-runtime";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
registerIMessageApprovalReactionTarget,
unregisterIMessageApprovalReactionTarget,
@@ -30,7 +25,6 @@ import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js";
const log = createSubsystemLogger("imessage/approvals");
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved;
type IMessagePendingDelivery = {
text: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
@@ -66,42 +60,6 @@ function buildPendingPayload(params: {
};
}
function buildResolvedText(params: {
request: ApprovalRequest;
resolved: ApprovalResolved;
view: ResolvedApprovalView;
}): string {
if (params.view.approvalKind === "plugin") {
return buildPluginApprovalResolvedMessage(params.resolved as PluginApprovalResolved);
}
const resolvedByText = params.resolved.resolvedBy
? ` Resolved by ${params.resolved.resolvedBy}.`
: "";
const payload = buildApprovalResolvedReplyPayload({
approvalId: params.request.id,
approvalSlug: params.request.id.slice(0, 8),
text: `✅ Exec approval ${params.resolved.decision}.${resolvedByText} ID: ${params.request.id}`,
});
return payload.text ?? "";
}
function buildExpiredText(params: { request: ApprovalRequest; view: ExpiredApprovalView }): string {
if (params.view.approvalKind === "plugin") {
return buildPluginApprovalExpiredMessage(params.request as PluginApprovalRequest);
}
return `⏱️ Exec approval expired. ID: ${params.request.id}`;
}
function resolvePreparedAccountId(params: {
plannedAccountId?: string | null;
contextAccountId?: string | null;
}): string | undefined {
return (
normalizeOptionalString(params.plannedAccountId) ??
normalizeOptionalString(params.contextAccountId)
);
}
function buildConversationKeyForTarget(to: string): IMessageApprovalConversationKey | null {
try {
const parsed = parseIMessageTarget(to);
@@ -138,11 +96,11 @@ export const imessageApprovalNativeRuntime = createChannelApprovalNativeRuntimeA
buildPendingPayload({ request, approvalKind, nowMs, view }),
buildResolvedResult: ({ request, resolved, view }) => ({
kind: "update",
payload: { text: buildResolvedText({ request, resolved, view }) },
payload: { text: buildChannelApprovalResolvedText({ request, resolved, view }) },
}),
buildExpiredResult: ({ request, view }) => ({
kind: "update",
payload: { text: buildExpiredText({ request, view }) },
payload: { text: buildChannelApprovalExpiredText({ request, view }) },
}),
},
transport: {
@@ -153,7 +111,7 @@ export const imessageApprovalNativeRuntime = createChannelApprovalNativeRuntimeA
}
const prepared: PreparedIMessageApprovalTarget = {
to,
accountId: resolvePreparedAccountId({
accountId: resolvePreparedApprovalAccountId({
plannedAccountId: (plannedTarget.target as { accountId?: string | null }).accountId,
contextAccountId: accountId,
}),

View File

@@ -1,4 +1,3 @@
import { matchesApprovalRequestFilters } from "openclaw/plugin-sdk/approval-client-runtime";
import {
createChannelApprovalCapability,
splitChannelApprovalCapability,
@@ -6,9 +5,11 @@ import {
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import {
createChannelApprovalForwardingEvaluator,
createChannelApproverDmTargetResolver,
createChannelNativeOriginTargetResolver,
doesApprovalRequestMatchChannelAccount,
createNativeApprovalForwardingFallbackSuppressor,
nativeApprovalTargetsMatch,
resolveApprovalRequestSessionTarget,
shouldSuppressLocalNativeExecApprovalPrompt,
} from "openclaw/plugin-sdk/approval-native-runtime";
@@ -28,7 +29,6 @@ import type {
ChannelApprovalCapability,
ChannelOutboundPayloadHint,
} from "openclaw/plugin-sdk/channel-contract";
import { channelRouteTargetsMatchExact } from "openclaw/plugin-sdk/channel-route";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeAccountId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
@@ -50,7 +50,6 @@ import { inferIMessageTargetChatType } from "./targets.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalKind = "exec" | "plugin";
type ApprovalForwardingConfig = NonNullable<NonNullable<OpenClawConfig["approvals"]>["exec"]>;
type ApprovalForwardingMode = NonNullable<ApprovalForwardingConfig["mode"]>;
type ChannelApprovalForwardTarget = Parameters<
NonNullable<
NonNullable<ChannelApprovalCapability["delivery"]>["shouldSuppressForwardingFallback"]
@@ -62,7 +61,6 @@ type IMessageApprovalTarget = {
threadId?: string | number | null;
};
const DEFAULT_APPROVAL_FORWARDING_MODE: ApprovalForwardingMode = "session";
const DEFAULT_PLUGIN_APPROVAL_DECISIONS: readonly ExecApprovalReplyDecision[] = [
"allow-once",
"allow-always",
@@ -76,48 +74,6 @@ function isIMessageApprovalTransportEnabled(params: {
return resolveIMessageAccount({ cfg: params.cfg, accountId: params.accountId }).enabled;
}
function resolveApprovalKind(request: ApprovalRequest, approvalKind?: ApprovalKind): ApprovalKind {
if (approvalKind) {
return approvalKind;
}
return "command" in request.request ? "exec" : "plugin";
}
function resolveApprovalForwardingConfig(params: {
cfg: OpenClawConfig;
approvalKind: ApprovalKind;
}): ApprovalForwardingConfig | undefined {
return params.approvalKind === "plugin"
? params.cfg.approvals?.plugin
: params.cfg.approvals?.exec;
}
function normalizeApprovalForwardingMode(
mode: ApprovalForwardingConfig["mode"] | undefined,
): ApprovalForwardingMode {
return mode ?? DEFAULT_APPROVAL_FORWARDING_MODE;
}
function approvalModeIncludesSession(mode: ApprovalForwardingMode): boolean {
return mode === "session" || mode === "both";
}
function approvalModeIncludesTargets(mode: ApprovalForwardingMode): boolean {
return mode === "targets" || mode === "both";
}
function matchesForwardingFilters(params: {
config: ApprovalForwardingConfig;
request: ApprovalRequest;
}): boolean {
return matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: params.config.agentFilter,
sessionFilter: params.config.sessionFilter,
fallbackAgentIdFromSessionKey: true,
});
}
function targetAccountMatchesIMessageAccount(params: {
cfg: OpenClawConfig;
targetAccountId?: string | null;
@@ -164,26 +120,6 @@ function normalizeIMessageForwardTarget(
};
}
function nativeApprovalTargetsMatch(params: {
left: IMessageApprovalTarget;
right: IMessageApprovalTarget;
}): boolean {
return channelRouteTargetsMatchExact({
left: {
channel: "imessage",
to: params.left.to,
accountId: params.left.accountId,
threadId: params.left.threadId,
},
right: {
channel: "imessage",
to: params.right.to,
accountId: params.right.accountId,
threadId: params.right.threadId,
},
});
}
function hasMatchingIMessageTarget(params: {
cfg: OpenClawConfig;
config: ApprovalForwardingConfig;
@@ -208,7 +144,11 @@ function hasMatchingIMessageTarget(params: {
if (!candidateTarget) {
return true;
}
return nativeApprovalTargetsMatch({ left: configuredTarget, right: candidateTarget });
return nativeApprovalTargetsMatch({
channel: "imessage",
left: configuredTarget,
right: candidateTarget,
});
});
}
@@ -235,118 +175,17 @@ function hasIMessageOriginOrSessionTarget(params: {
);
}
function canApprovalPotentiallyRouteToIMessage(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
nativeSessionOnly?: boolean;
}): boolean {
if (!isIMessageApprovalTransportEnabled(params)) {
return false;
}
const config = resolveApprovalForwardingConfig(params);
if (!config?.enabled) {
return false;
}
const mode = normalizeApprovalForwardingMode(config.mode);
if (approvalModeIncludesSession(mode)) {
return true;
}
if (params.nativeSessionOnly) {
return false;
}
return (
approvalModeIncludesTargets(mode) &&
hasMatchingIMessageTarget({
cfg: params.cfg,
config,
accountId: params.accountId,
})
);
}
const imessageApprovalForwarding = createChannelApprovalForwardingEvaluator({
channel: "imessage",
isTransportEnabled: isIMessageApprovalTransportEnabled,
hasMatchingTarget: hasMatchingIMessageTarget,
hasOriginOrSessionTarget: hasIMessageOriginOrSessionTarget,
});
function canAnyApprovalPotentiallyRouteToIMessage(params: {
cfg: OpenClawConfig;
accountId?: string | null;
nativeSessionOnly?: boolean;
}): boolean {
return (
canApprovalPotentiallyRouteToIMessage({
...params,
approvalKind: "exec",
}) ||
canApprovalPotentiallyRouteToIMessage({
...params,
approvalKind: "plugin",
})
);
}
function isIMessageSessionApprovalEligible(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
request: ApprovalRequest;
}): boolean {
if (!isIMessageApprovalTransportEnabled(params)) {
return false;
}
const config = resolveApprovalForwardingConfig(params);
if (!config?.enabled) {
return false;
}
const mode = normalizeApprovalForwardingMode(config.mode);
if (!approvalModeIncludesSession(mode)) {
return false;
}
if (!matchesForwardingFilters({ config, request: params.request })) {
return false;
}
if (
!doesApprovalRequestMatchChannelAccount({
cfg: params.cfg,
request: params.request,
channel: "imessage",
accountId: params.accountId,
})
) {
return false;
}
return hasIMessageOriginOrSessionTarget({
cfg: params.cfg,
accountId: params.accountId,
request: params.request,
});
}
function isIMessageExplicitTargetEligible(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
request: ApprovalRequest;
target: ChannelApprovalForwardTarget;
}): boolean {
if (!isIMessageApprovalTransportEnabled(params)) {
return false;
}
const config = resolveApprovalForwardingConfig(params);
if (!config?.enabled) {
return false;
}
const mode = normalizeApprovalForwardingMode(config.mode);
if (!approvalModeIncludesTargets(mode)) {
return false;
}
if (!matchesForwardingFilters({ config, request: params.request })) {
return false;
}
return hasMatchingIMessageTarget({
cfg: params.cfg,
config,
accountId: params.accountId,
target: params.target,
});
}
const canApprovalPotentiallyRouteToIMessage = imessageApprovalForwarding.isPotentialRoute;
const canAnyApprovalPotentiallyRouteToIMessage = imessageApprovalForwarding.canAnyPotentiallyRoute;
const isIMessageSessionApprovalEligible = imessageApprovalForwarding.isSessionEligible;
const isIMessageExplicitTargetEligible = imessageApprovalForwarding.isExplicitTargetEligible;
function resolveTurnSourceIMessageOriginTarget(
request: ApprovalRequest,
@@ -475,10 +314,7 @@ function shouldHandleIMessageApprovalRequest(params: {
approvalKind?: ApprovalKind;
request: ApprovalRequest;
}): boolean {
return isIMessageSessionApprovalEligible({
...params,
approvalKind: resolveApprovalKind(params.request, params.approvalKind),
});
return imessageApprovalForwarding.shouldHandleRequest(params);
}
const resolveIMessageOriginTargetBase = createChannelNativeOriginTargetResolver({
@@ -528,6 +364,22 @@ const resolveIMessageApproverDmTargets = createChannelApproverDmTargetResolver({
},
});
const shouldSuppressIMessageForwardingFallback =
createNativeApprovalForwardingFallbackSuppressor<IMessageApprovalTarget>({
channel: "imessage",
normalizeForwardTarget: normalizeIMessageForwardTarget,
resolveAccountId: ({ forwardingTarget, request }) =>
forwardingTarget.accountId ?? normalizeOptionalString(request.request.turnSourceAccountId),
resolveForwardingTargetForMatch: ({ forwardingTarget, accountId }) => ({
...forwardingTarget,
accountId,
}),
isSessionRouteEligible: isIMessageSessionApprovalEligible,
isExplicitTargetEligible: isIMessageExplicitTargetEligible,
resolveOriginTarget: resolveIMessageOriginTarget,
resolveApproverDmTargets: resolveIMessageApproverDmTargets,
});
function appendIMessageReactionHint(params: {
text?: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
@@ -625,58 +477,7 @@ export const imessageApprovalCapability: ChannelApprovalCapability =
}
return getIMessageApprovalApprovers({ cfg, accountId }).length > 0;
}),
shouldSuppressForwardingFallback: ({ cfg, approvalKind, target, request }) => {
const forwardingTarget = normalizeIMessageForwardTarget(target);
if (!forwardingTarget) {
return false;
}
const accountId =
forwardingTarget.accountId ??
normalizeOptionalString(request.request.turnSourceAccountId);
const forwardingTargetForMatch = {
...forwardingTarget,
accountId,
};
const kind = resolveApprovalKind(request, approvalKind);
const eligible =
target.source === "target"
? isIMessageExplicitTargetEligible({
cfg,
accountId,
approvalKind: kind,
request,
target,
})
: isIMessageSessionApprovalEligible({
cfg,
accountId,
approvalKind: kind,
request,
});
if (!eligible) {
return false;
}
const originTarget = resolveIMessageOriginTarget({
cfg,
accountId,
approvalKind: kind,
request,
});
if (
originTarget &&
nativeApprovalTargetsMatch({ left: forwardingTargetForMatch, right: originTarget })
) {
return true;
}
return resolveIMessageApproverDmTargets({
cfg,
accountId,
approvalKind: kind,
request,
}).some((approverTarget) =>
nativeApprovalTargetsMatch({ left: forwardingTargetForMatch, right: approverTarget }),
);
},
shouldSuppressForwardingFallback: shouldSuppressIMessageForwardingFallback,
},
render: {
exec: {

View File

@@ -1,9 +1,10 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import {
buildChannelApprovalExpiredText,
buildChannelApprovalResolvedText,
createChannelApprovalNativeRuntimeAdapter,
type ExpiredApprovalView,
type PendingApprovalView,
type ResolvedApprovalView,
resolvePreparedApprovalAccountId,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import {
@@ -11,13 +12,8 @@ import {
type ApprovalReactionPendingContent,
} from "openclaw/plugin-sdk/approval-reaction-runtime";
import {
buildApprovalResolvedReplyPayload,
buildPluginApprovalExpiredMessage,
buildPluginApprovalResolvedMessage,
type ExecApprovalRequest,
type ExecApprovalResolved,
type PluginApprovalRequest,
type PluginApprovalResolved,
} from "openclaw/plugin-sdk/approval-runtime";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -34,7 +30,6 @@ import { sendMessageSignal, sendTypingSignal } from "./send.js";
const log = createSubsystemLogger("signal/approvals");
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved;
type SignalPendingDelivery = ApprovalReactionPendingContent;
type PreparedSignalApprovalTarget = {
to: string;
@@ -89,43 +84,6 @@ function buildPendingPayload(params: {
return buildApprovalReactionPendingContent(params);
}
function buildResolvedText(params: {
request: ApprovalRequest;
resolved: ApprovalResolved;
view: ResolvedApprovalView;
}): string {
if (params.view.approvalKind === "plugin") {
return buildPluginApprovalResolvedMessage(params.resolved as PluginApprovalResolved);
}
const resolvedByText = params.resolved.resolvedBy
? ` Resolved by ${params.resolved.resolvedBy}.`
: "";
const payload = buildApprovalResolvedReplyPayload({
approvalId: params.request.id,
approvalSlug: params.request.id.slice(0, 8),
text: `✅ Exec approval ${params.resolved.decision}.${resolvedByText} ID: ${params.request.id}`,
});
return payload.text ?? "";
}
function buildExpiredText(params: { request: ApprovalRequest; view: ExpiredApprovalView }): string {
if (params.view.approvalKind === "plugin") {
return buildPluginApprovalExpiredMessage(params.request as PluginApprovalRequest);
}
return `⏱️ Exec approval expired. ID: ${params.request.id}`;
}
function resolvePreparedAccountId(params: {
plannedAccountId?: string | null;
contextAccountId?: string | null;
}): string {
return (
normalizeOptionalString(params.plannedAccountId) ??
normalizeOptionalString(params.contextAccountId) ??
DEFAULT_ACCOUNT_ID
);
}
export const signalApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
SignalPendingDelivery,
PreparedSignalApprovalTarget,
@@ -143,11 +101,11 @@ export const signalApprovalNativeRuntime = createChannelApprovalNativeRuntimeAda
buildPendingPayload({ request, nowMs, view }),
buildResolvedResult: ({ request, resolved, view }) => ({
kind: "update",
payload: { text: buildResolvedText({ request, resolved, view }) },
payload: { text: buildChannelApprovalResolvedText({ request, resolved, view }) },
}),
buildExpiredResult: ({ request, view }) => ({
kind: "update",
payload: { text: buildExpiredText({ request, view }) },
payload: { text: buildChannelApprovalExpiredText({ request, view }) },
}),
},
transport: {
@@ -163,9 +121,10 @@ export const signalApprovalNativeRuntime = createChannelApprovalNativeRuntimeAda
});
const prepared: PreparedSignalApprovalTarget = {
to,
accountId: resolvePreparedAccountId({
accountId: resolvePreparedApprovalAccountId({
plannedAccountId: (plannedTarget.target as { accountId?: string | null }).accountId,
contextAccountId: accountId,
fallbackAccountId: DEFAULT_ACCOUNT_ID,
}),
...(runtimeContext.baseUrl ? { baseUrl: runtimeContext.baseUrl } : {}),
...(runtimeContext.account ? { account: runtimeContext.account } : {}),

View File

@@ -1,4 +1,3 @@
import { matchesApprovalRequestFilters } from "openclaw/plugin-sdk/approval-client-runtime";
import {
createChannelApprovalCapability,
splitChannelApprovalCapability,
@@ -6,10 +5,10 @@ import {
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import {
createChannelApprovalForwardingEvaluator,
createChannelApproverDmTargetResolver,
createChannelNativeOriginTargetResolver,
createNativeApprovalForwardingFallbackSuppressor,
doesApprovalRequestMatchChannelAccount,
nativeApprovalTargetsMatch,
resolveApprovalRequestSessionTarget,
shouldSuppressLocalNativeExecApprovalPrompt,
@@ -41,7 +40,6 @@ import { normalizeSignalMessagingTarget } from "./normalize.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalKind = "exec" | "plugin";
type ApprovalForwardingConfig = NonNullable<NonNullable<OpenClawConfig["approvals"]>["exec"]>;
type ApprovalForwardingMode = NonNullable<ApprovalForwardingConfig["mode"]>;
type ChannelApprovalForwardTarget = Parameters<
NonNullable<
NonNullable<ChannelApprovalCapability["delivery"]>["shouldSuppressForwardingFallback"]
@@ -53,8 +51,6 @@ type SignalApprovalTarget = {
threadId?: string | number | null;
};
const DEFAULT_APPROVAL_FORWARDING_MODE: ApprovalForwardingMode = "session";
function isSignalApprovalTransportEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
@@ -62,48 +58,6 @@ function isSignalApprovalTransportEnabled(params: {
return resolveSignalAccount({ cfg: params.cfg, accountId: params.accountId }).enabled;
}
function resolveApprovalKind(request: ApprovalRequest, approvalKind?: ApprovalKind): ApprovalKind {
if (approvalKind) {
return approvalKind;
}
return "command" in request.request ? "exec" : "plugin";
}
function resolveApprovalForwardingConfig(params: {
cfg: OpenClawConfig;
approvalKind: ApprovalKind;
}): ApprovalForwardingConfig | undefined {
return params.approvalKind === "plugin"
? params.cfg.approvals?.plugin
: params.cfg.approvals?.exec;
}
function normalizeApprovalForwardingMode(
mode: ApprovalForwardingConfig["mode"] | undefined,
): ApprovalForwardingMode {
return mode ?? DEFAULT_APPROVAL_FORWARDING_MODE;
}
function approvalModeIncludesSession(mode: ApprovalForwardingMode): boolean {
return mode === "session" || mode === "both";
}
function approvalModeIncludesTargets(mode: ApprovalForwardingMode): boolean {
return mode === "targets" || mode === "both";
}
function matchesForwardingFilters(params: {
config: ApprovalForwardingConfig;
request: ApprovalRequest;
}): boolean {
return matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: params.config.agentFilter,
sessionFilter: params.config.sessionFilter,
fallbackAgentIdFromSessionKey: true,
});
}
function targetAccountMatchesSignalAccount(params: {
cfg: OpenClawConfig;
targetAccountId?: string | null;
@@ -205,52 +159,16 @@ function hasSignalOriginOrSessionTarget(params: {
);
}
function canApprovalPotentiallyRouteToSignal(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
nativeSessionOnly?: boolean;
}): boolean {
if (!isSignalApprovalTransportEnabled(params)) {
return false;
}
const config = resolveApprovalForwardingConfig(params);
if (!config?.enabled) {
return false;
}
const mode = normalizeApprovalForwardingMode(config.mode);
if (approvalModeIncludesSession(mode)) {
return true;
}
if (params.nativeSessionOnly) {
return false;
}
return (
approvalModeIncludesTargets(mode) &&
hasMatchingSignalTarget({
cfg: params.cfg,
config,
accountId: params.accountId,
})
);
}
const signalApprovalForwarding = createChannelApprovalForwardingEvaluator({
channel: "signal",
isTransportEnabled: isSignalApprovalTransportEnabled,
hasMatchingTarget: hasMatchingSignalTarget,
hasOriginOrSessionTarget: hasSignalOriginOrSessionTarget,
});
function canAnyApprovalPotentiallyRouteToSignal(params: {
cfg: OpenClawConfig;
accountId?: string | null;
nativeSessionOnly?: boolean;
}): boolean {
return (
canApprovalPotentiallyRouteToSignal({
...params,
approvalKind: "exec",
}) ||
canApprovalPotentiallyRouteToSignal({
...params,
approvalKind: "plugin",
})
);
}
const canApprovalPotentiallyRouteToSignal = signalApprovalForwarding.isPotentialRoute;
const canAnyApprovalPotentiallyRouteToSignal = signalApprovalForwarding.canAnyPotentiallyRoute;
const isSignalSessionApprovalEligible = signalApprovalForwarding.isSessionEligible;
export function isSignalNativeApprovalHandlerConfigured(params: {
cfg: OpenClawConfig;
@@ -262,43 +180,6 @@ export function isSignalNativeApprovalHandlerConfigured(params: {
});
}
function isSignalSessionApprovalEligible(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
request: ApprovalRequest;
}): boolean {
if (!isSignalApprovalTransportEnabled(params)) {
return false;
}
const config = resolveApprovalForwardingConfig(params);
if (!config?.enabled) {
return false;
}
const mode = normalizeApprovalForwardingMode(config.mode);
if (!approvalModeIncludesSession(mode)) {
return false;
}
if (!matchesForwardingFilters({ config, request: params.request })) {
return false;
}
if (
!doesApprovalRequestMatchChannelAccount({
cfg: params.cfg,
request: params.request,
channel: "signal",
accountId: params.accountId,
})
) {
return false;
}
return hasSignalOriginOrSessionTarget({
cfg: params.cfg,
accountId: params.accountId,
request: params.request,
});
}
function resolveTurnSourceSignalOriginTarget(
request: ApprovalRequest,
): SignalApprovalTarget | null {
@@ -330,10 +211,7 @@ function shouldHandleSignalApprovalRequest(params: {
approvalKind?: ApprovalKind;
request: ApprovalRequest;
}): boolean {
return isSignalSessionApprovalEligible({
...params,
approvalKind: resolveApprovalKind(params.request, params.approvalKind),
});
return signalApprovalForwarding.shouldHandleRequest(params);
}
function resolveSignalSessionTargetFromSessionKey(sessionKey?: string | null): string | null {

View File

@@ -1,8 +1,9 @@
import {
buildChannelApprovalExpiredText,
buildChannelApprovalResolvedText,
createChannelApprovalNativeRuntimeAdapter,
type ExpiredApprovalView,
type PendingApprovalView,
type ResolvedApprovalView,
resolvePreparedApprovalAccountId,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import {
@@ -10,16 +11,10 @@ import {
type ApprovalReactionPendingContent,
} from "openclaw/plugin-sdk/approval-reaction-runtime";
import {
buildApprovalResolvedReplyPayload,
buildPluginApprovalExpiredMessage,
buildPluginApprovalResolvedMessage,
type ExecApprovalRequest,
type ExecApprovalResolved,
type PluginApprovalRequest,
type PluginApprovalResolved,
} from "openclaw/plugin-sdk/approval-runtime";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
registerWhatsAppApprovalReactionTarget,
unregisterWhatsAppApprovalReactionTarget,
@@ -31,7 +26,6 @@ import { sendMessageWhatsApp, sendTypingWhatsApp } from "./send.js";
const log = createSubsystemLogger("whatsapp/approvals");
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved;
type WhatsAppPendingDelivery = ApprovalReactionPendingContent;
type PreparedWhatsAppApprovalTarget = {
to: string;
@@ -55,42 +49,6 @@ function buildPendingPayload(params: {
return buildApprovalReactionPendingContent(params);
}
function buildResolvedText(params: {
request: ApprovalRequest;
resolved: ApprovalResolved;
view: ResolvedApprovalView;
}): string {
if (params.view.approvalKind === "plugin") {
return buildPluginApprovalResolvedMessage(params.resolved as PluginApprovalResolved);
}
const resolvedByText = params.resolved.resolvedBy
? ` Resolved by ${params.resolved.resolvedBy}.`
: "";
const payload = buildApprovalResolvedReplyPayload({
approvalId: params.request.id,
approvalSlug: params.request.id.slice(0, 8),
text: `✅ Exec approval ${params.resolved.decision}.${resolvedByText} ID: ${params.request.id}`,
});
return payload.text ?? "";
}
function buildExpiredText(params: { request: ApprovalRequest; view: ExpiredApprovalView }): string {
if (params.view.approvalKind === "plugin") {
return buildPluginApprovalExpiredMessage(params.request as PluginApprovalRequest);
}
return `⏱️ Exec approval expired. ID: ${params.request.id}`;
}
function resolvePreparedAccountId(params: {
plannedAccountId?: string | null;
contextAccountId?: string | null;
}): string | undefined {
return (
normalizeOptionalString(params.plannedAccountId) ??
normalizeOptionalString(params.contextAccountId)
);
}
export const whatsappApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
WhatsAppPendingDelivery,
PreparedWhatsAppApprovalTarget,
@@ -108,11 +66,11 @@ export const whatsappApprovalNativeRuntime = createChannelApprovalNativeRuntimeA
buildPendingPayload({ request, view, nowMs }),
buildResolvedResult: ({ request, resolved, view }) => ({
kind: "update",
payload: { text: buildResolvedText({ request, resolved, view }) },
payload: { text: buildChannelApprovalResolvedText({ request, resolved, view }) },
}),
buildExpiredResult: ({ request, view }) => ({
kind: "update",
payload: { text: buildExpiredText({ request, view }) },
payload: { text: buildChannelApprovalExpiredText({ request, view }) },
}),
},
transport: {
@@ -123,7 +81,7 @@ export const whatsappApprovalNativeRuntime = createChannelApprovalNativeRuntimeA
}
const prepared: PreparedWhatsAppApprovalTarget = {
to,
accountId: resolvePreparedAccountId({
accountId: resolvePreparedApprovalAccountId({
plannedAccountId: (plannedTarget.target as { accountId?: string | null }).accountId,
contextAccountId: accountId,
}),

View File

@@ -1,4 +1,3 @@
import { matchesApprovalRequestFilters } from "openclaw/plugin-sdk/approval-client-runtime";
import {
createChannelApprovalCapability,
splitChannelApprovalCapability,
@@ -6,10 +5,10 @@ import {
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import {
createChannelApprovalForwardingEvaluator,
createChannelApproverDmTargetResolver,
createChannelNativeOriginTargetResolver,
createNativeApprovalForwardingFallbackSuppressor,
doesApprovalRequestMatchChannelAccount,
nativeApprovalTargetsMatch,
resolveApprovalRequestSessionTarget,
} from "openclaw/plugin-sdk/approval-native-runtime";
@@ -36,7 +35,6 @@ import { isWhatsAppGroupJid, normalizeWhatsAppMessagingTarget } from "./normaliz
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalKind = "exec" | "plugin";
type ApprovalForwardingConfig = NonNullable<NonNullable<OpenClawConfig["approvals"]>["exec"]>;
type ApprovalForwardingMode = NonNullable<ApprovalForwardingConfig["mode"]>;
type ChannelApprovalForwardTarget = Parameters<
NonNullable<
NonNullable<ChannelApprovalCapability["delivery"]>["shouldSuppressForwardingFallback"]
@@ -48,8 +46,6 @@ type WhatsAppApprovalTarget = {
threadId?: string | number | null;
};
const DEFAULT_APPROVAL_FORWARDING_MODE: ApprovalForwardingMode = "session";
function isWhatsAppApprovalTransportEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
@@ -57,48 +53,6 @@ function isWhatsAppApprovalTransportEnabled(params: {
return resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId }).enabled;
}
function resolveApprovalKind(request: ApprovalRequest, approvalKind?: ApprovalKind): ApprovalKind {
if (approvalKind) {
return approvalKind;
}
return "command" in request.request ? "exec" : "plugin";
}
function resolveApprovalForwardingConfig(params: {
cfg: OpenClawConfig;
approvalKind: ApprovalKind;
}): ApprovalForwardingConfig | undefined {
return params.approvalKind === "plugin"
? params.cfg.approvals?.plugin
: params.cfg.approvals?.exec;
}
function normalizeApprovalForwardingMode(
mode: ApprovalForwardingConfig["mode"] | undefined,
): ApprovalForwardingMode {
return mode ?? DEFAULT_APPROVAL_FORWARDING_MODE;
}
function approvalModeIncludesSession(mode: ApprovalForwardingMode): boolean {
return mode === "session" || mode === "both";
}
function approvalModeIncludesTargets(mode: ApprovalForwardingMode): boolean {
return mode === "targets" || mode === "both";
}
function matchesForwardingFilters(params: {
config: ApprovalForwardingConfig;
request: ApprovalRequest;
}): boolean {
return matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: params.config.agentFilter,
sessionFilter: params.config.sessionFilter,
fallbackAgentIdFromSessionKey: true,
});
}
function targetAccountMatchesWhatsAppAccount(params: {
cfg: OpenClawConfig;
targetAccountId?: string | null;
@@ -200,118 +154,17 @@ function hasWhatsAppOriginOrSessionTarget(params: {
);
}
function canApprovalPotentiallyRouteToWhatsApp(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
nativeSessionOnly?: boolean;
}): boolean {
if (!isWhatsAppApprovalTransportEnabled(params)) {
return false;
}
const config = resolveApprovalForwardingConfig(params);
if (!config?.enabled) {
return false;
}
const mode = normalizeApprovalForwardingMode(config.mode);
if (approvalModeIncludesSession(mode)) {
return true;
}
if (params.nativeSessionOnly) {
return false;
}
return (
approvalModeIncludesTargets(mode) &&
hasMatchingWhatsAppTarget({
cfg: params.cfg,
config,
accountId: params.accountId,
})
);
}
const whatsappApprovalForwarding = createChannelApprovalForwardingEvaluator({
channel: "whatsapp",
isTransportEnabled: isWhatsAppApprovalTransportEnabled,
hasMatchingTarget: hasMatchingWhatsAppTarget,
hasOriginOrSessionTarget: hasWhatsAppOriginOrSessionTarget,
});
function canAnyApprovalPotentiallyRouteToWhatsApp(params: {
cfg: OpenClawConfig;
accountId?: string | null;
nativeSessionOnly?: boolean;
}): boolean {
return (
canApprovalPotentiallyRouteToWhatsApp({
...params,
approvalKind: "exec",
}) ||
canApprovalPotentiallyRouteToWhatsApp({
...params,
approvalKind: "plugin",
})
);
}
function isWhatsAppSessionApprovalEligible(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
request: ApprovalRequest;
}): boolean {
if (!isWhatsAppApprovalTransportEnabled(params)) {
return false;
}
const config = resolveApprovalForwardingConfig(params);
if (!config?.enabled) {
return false;
}
const mode = normalizeApprovalForwardingMode(config.mode);
if (!approvalModeIncludesSession(mode)) {
return false;
}
if (!matchesForwardingFilters({ config, request: params.request })) {
return false;
}
if (
!doesApprovalRequestMatchChannelAccount({
cfg: params.cfg,
request: params.request,
channel: "whatsapp",
accountId: params.accountId,
})
) {
return false;
}
return hasWhatsAppOriginOrSessionTarget({
cfg: params.cfg,
accountId: params.accountId,
request: params.request,
});
}
function isWhatsAppExplicitTargetEligible(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
request: ApprovalRequest;
target: ChannelApprovalForwardTarget;
}): boolean {
if (!isWhatsAppApprovalTransportEnabled(params)) {
return false;
}
const config = resolveApprovalForwardingConfig(params);
if (!config?.enabled) {
return false;
}
const mode = normalizeApprovalForwardingMode(config.mode);
if (!approvalModeIncludesTargets(mode)) {
return false;
}
if (!matchesForwardingFilters({ config, request: params.request })) {
return false;
}
return hasMatchingWhatsAppTarget({
cfg: params.cfg,
config,
accountId: params.accountId,
target: params.target,
});
}
const canApprovalPotentiallyRouteToWhatsApp = whatsappApprovalForwarding.isPotentialRoute;
const canAnyApprovalPotentiallyRouteToWhatsApp = whatsappApprovalForwarding.canAnyPotentiallyRoute;
const isWhatsAppSessionApprovalEligible = whatsappApprovalForwarding.isSessionEligible;
const isWhatsAppExplicitTargetEligible = whatsappApprovalForwarding.isExplicitTargetEligible;
function resolveTurnSourceWhatsAppOriginTarget(
request: ApprovalRequest,
@@ -344,10 +197,7 @@ function shouldHandleWhatsAppApprovalRequest(params: {
approvalKind?: ApprovalKind;
request: ApprovalRequest;
}): boolean {
return isWhatsAppSessionApprovalEligible({
...params,
approvalKind: resolveApprovalKind(params.request, params.approvalKind),
});
return whatsappApprovalForwarding.shouldHandleRequest(params);
}
const resolveWhatsAppOriginTargetBase = createChannelNativeOriginTargetResolver({

View File

@@ -29,3 +29,70 @@ export {
type ResolvedApprovalView,
} from "../infra/approval-handler-runtime.js";
export { resolveApprovalOverGateway } from "./approval-gateway-runtime.js";
import type {
ExpiredApprovalView,
ResolvedApprovalView,
} from "../infra/approval-view-model.types.js";
import type { ExecApprovalRequest, ExecApprovalResolved } from "../infra/exec-approvals.js";
import {
buildPluginApprovalExpiredMessage,
buildPluginApprovalResolvedMessage,
type PluginApprovalRequest,
type PluginApprovalResolved,
} from "../infra/plugin-approvals.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { buildApprovalResolvedReplyPayload } from "./approval-renderers.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved;
export function buildChannelApprovalResolvedText(params: {
request: ApprovalRequest;
resolved: ApprovalResolved;
view: ResolvedApprovalView;
}): string {
if (params.view.approvalKind === "plugin") {
return buildPluginApprovalResolvedMessage(params.resolved as PluginApprovalResolved);
}
const resolvedByText = params.resolved.resolvedBy
? ` Resolved by ${params.resolved.resolvedBy}.`
: "";
const payload = buildApprovalResolvedReplyPayload({
approvalId: params.request.id,
approvalSlug: params.request.id.slice(0, 8),
text: `✅ Exec approval ${params.resolved.decision}.${resolvedByText} ID: ${params.request.id}`,
});
return payload.text ?? "";
}
export function buildChannelApprovalExpiredText(params: {
request: ApprovalRequest;
view: ExpiredApprovalView;
}): string {
if (params.view.approvalKind === "plugin") {
return buildPluginApprovalExpiredMessage(params.request as PluginApprovalRequest);
}
return `⏱️ Exec approval expired. ID: ${params.request.id}`;
}
export function resolvePreparedApprovalAccountId(params: {
plannedAccountId?: string | null;
contextAccountId?: string | null;
fallbackAccountId: string;
}): string;
export function resolvePreparedApprovalAccountId(params: {
plannedAccountId?: string | null;
contextAccountId?: string | null;
fallbackAccountId?: string | null;
}): string | undefined;
export function resolvePreparedApprovalAccountId(params: {
plannedAccountId?: string | null;
contextAccountId?: string | null;
fallbackAccountId?: string | null;
}): string | undefined {
return (
normalizeOptionalString(params.plannedAccountId) ??
normalizeOptionalString(params.contextAccountId) ??
normalizeOptionalString(params.fallbackAccountId)
);
}

View File

@@ -1,3 +1,8 @@
import type {
ExecApprovalForwardingConfig,
ExecApprovalForwardingMode,
} from "../config/types.approvals.js";
import { doesApprovalRequestMatchChannelAccount } from "../infra/approval-request-account-binding.js";
import { matchesApprovalRequestFilters } from "../infra/approval-request-filters.js";
import {
getExecApprovalReplyMetadata,
@@ -26,6 +31,7 @@ type LocalNativeExecApprovalConfig = {
agentFilter?: string[];
sessionFilter?: string[];
};
type ChannelApprovalForwardTarget = DeliverySuppressionInput["target"];
type ApprovalResolverParams = {
cfg: OpenClawConfig;
@@ -34,6 +40,41 @@ type ApprovalResolverParams = {
request: ApprovalRequest;
};
type ChannelApprovalForwardingEvaluatorParams = {
channel: string;
isTransportEnabled: (params: { cfg: OpenClawConfig; accountId?: string | null }) => boolean;
hasMatchingTarget: (params: {
cfg: OpenClawConfig;
config: ExecApprovalForwardingConfig;
accountId?: string | null;
target?: ChannelApprovalForwardTarget;
}) => boolean;
hasOriginOrSessionTarget: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
}) => boolean;
};
export type ChannelApprovalForwardingEligibilityParams = {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
request: ApprovalRequest;
};
export type ChannelApprovalPotentialRouteParams = {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: ApprovalKind;
nativeSessionOnly?: boolean;
};
export type ChannelApprovalExplicitTargetEligibilityParams =
ChannelApprovalForwardingEligibilityParams & {
target: ChannelApprovalForwardTarget;
};
type NativeApprovalTargetNormalizer<TTarget> = (
target: TTarget,
request: ApprovalRequest,
@@ -230,13 +271,170 @@ function nativeApprovalTargetMatcher(channel: string): (left: unknown, right: un
nativeApprovalTargetsMatch({ channel, left, right });
}
function resolveApprovalKind(request: ApprovalRequest, approvalKind?: ApprovalKind): ApprovalKind {
export function resolveApprovalKind(
request: ApprovalRequest,
approvalKind?: ApprovalKind,
): ApprovalKind {
if (approvalKind) {
return approvalKind;
}
return "command" in request.request ? "exec" : "plugin";
}
function resolveApprovalForwardingConfig(params: {
cfg: OpenClawConfig;
approvalKind: ApprovalKind;
}): ExecApprovalForwardingConfig | undefined {
return params.approvalKind === "plugin"
? params.cfg.approvals?.plugin
: params.cfg.approvals?.exec;
}
function normalizeApprovalForwardingMode(
mode: ExecApprovalForwardingConfig["mode"] | undefined,
): ExecApprovalForwardingMode {
return mode ?? "session";
}
function approvalModeIncludesSession(mode: ExecApprovalForwardingMode): boolean {
return mode === "session" || mode === "both";
}
function approvalModeIncludesTargets(mode: ExecApprovalForwardingMode): boolean {
return mode === "targets" || mode === "both";
}
function matchesForwardingFilters(params: {
config: ExecApprovalForwardingConfig;
request: ApprovalRequest;
}): boolean {
return matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: params.config.agentFilter,
sessionFilter: params.config.sessionFilter,
fallbackAgentIdFromSessionKey: true,
});
}
export function createChannelApprovalForwardingEvaluator(
params: ChannelApprovalForwardingEvaluatorParams,
) {
const isPotentialRoute = (input: ChannelApprovalPotentialRouteParams): boolean => {
if (!params.isTransportEnabled(input)) {
return false;
}
const config = resolveApprovalForwardingConfig(input);
if (!config?.enabled) {
return false;
}
const mode = normalizeApprovalForwardingMode(config.mode);
if (approvalModeIncludesSession(mode)) {
return true;
}
if (input.nativeSessionOnly) {
return false;
}
return (
approvalModeIncludesTargets(mode) &&
params.hasMatchingTarget({
cfg: input.cfg,
config,
accountId: input.accountId,
})
);
};
const isSessionEligible = (input: ChannelApprovalForwardingEligibilityParams): boolean => {
if (!params.isTransportEnabled(input)) {
return false;
}
const config = resolveApprovalForwardingConfig(input);
if (!config?.enabled) {
return false;
}
const mode = normalizeApprovalForwardingMode(config.mode);
if (!approvalModeIncludesSession(mode)) {
return false;
}
if (!matchesForwardingFilters({ config, request: input.request })) {
return false;
}
if (
!doesApprovalRequestMatchChannelAccount({
cfg: input.cfg,
request: input.request,
channel: params.channel,
accountId: input.accountId,
})
) {
return false;
}
return params.hasOriginOrSessionTarget({
cfg: input.cfg,
accountId: input.accountId,
request: input.request,
});
};
const isExplicitTargetEligible = (
input: ChannelApprovalExplicitTargetEligibilityParams,
): boolean => {
if (!params.isTransportEnabled(input)) {
return false;
}
const config = resolveApprovalForwardingConfig(input);
if (!config?.enabled) {
return false;
}
const mode = normalizeApprovalForwardingMode(config.mode);
if (!approvalModeIncludesTargets(mode)) {
return false;
}
if (!matchesForwardingFilters({ config, request: input.request })) {
return false;
}
return params.hasMatchingTarget({
cfg: input.cfg,
config,
accountId: input.accountId,
target: input.target,
});
};
const canAnyPotentiallyRoute = (input: {
cfg: OpenClawConfig;
accountId?: string | null;
nativeSessionOnly?: boolean;
}): boolean =>
isPotentialRoute({
...input,
approvalKind: "exec",
}) ||
isPotentialRoute({
...input,
approvalKind: "plugin",
});
const shouldHandleRequest = (input: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind?: ApprovalKind;
request: ApprovalRequest;
}): boolean =>
isSessionEligible({
...input,
approvalKind: resolveApprovalKind(input.request, input.approvalKind),
});
return {
canAnyPotentiallyRoute,
isExplicitTargetEligible,
isPotentialRoute,
isSessionEligible,
shouldHandleRequest,
};
}
function normalizeOptionalAccountId(value?: string | null): string | undefined {
return value?.trim() || undefined;
}

View File

@@ -1,9 +1,14 @@
export {
createChannelApprovalForwardingEvaluator,
createChannelApproverDmTargetResolver,
createChannelNativeOriginTargetResolver,
createNativeApprovalForwardingFallbackSuppressor,
nativeApprovalTargetsMatch,
resolveApprovalKind,
shouldSuppressLocalNativeExecApprovalPrompt,
type ChannelApprovalExplicitTargetEligibilityParams,
type ChannelApprovalForwardingEligibilityParams,
type ChannelApprovalPotentialRouteParams,
} from "./approval-native-helpers.js";
export {
resolveApprovalRequestSessionConversation,