Files
adolf/extensions/signal/src/approval-reactions.ts
alvis bedb527145
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Vendor OpenClaw source as Adolf fork baseline
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
2026-07-05 09:36:54 +00:00

790 lines
25 KiB
TypeScript

// Signal plugin module implements approval reactions behavior.
import { matchesApprovalRequestFilters } from "openclaw/plugin-sdk/approval-client-runtime";
import {
addApprovalReactionHintToText,
buildApprovalReactionHint,
createApprovalReactionTargetStore,
hasApprovalReactionHintText,
listApprovalReactionBindings,
resolveApprovalReactionTarget,
type ApprovalReactionDecisionBinding,
type ApprovalReactionTargetRecord,
} from "openclaw/plugin-sdk/approval-reaction-runtime";
import {
getExecApprovalReplyMetadata,
type ExecApprovalReplyDecision,
} from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-runtime";
import { resolveSignalTarget } from "./aliases.js";
import { getSignalApprovalApprovers, signalApprovalAuth } from "./approval-auth.js";
import { looksLikeUuid } from "./identity.js";
import { normalizeSignalMessagingTarget } from "./normalize.js";
import { getOptionalSignalRuntime } from "./runtime.js";
const PERSISTENT_NAMESPACE = "signal.approval-reactions.v2";
const PERSISTENT_MAX_ENTRIES = 1000;
const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000;
export type SignalApprovalReactionBinding = ApprovalReactionDecisionBinding;
type SignalApprovalReactionResolution = {
approvalId: string;
approvalKind: ApprovalKind;
decision: ExecApprovalReplyDecision;
route: SignalApprovalReactionRoute;
};
type ApprovalKind = "exec" | "plugin";
type ApprovalForwardingConfig = NonNullable<NonNullable<OpenClawConfig["approvals"]>["exec"]>;
type ApprovalForwardingMode = NonNullable<ApprovalForwardingConfig["mode"]>;
type SignalApprovalReactionRoute =
| {
deliveryMode: "session";
agentId?: string;
sessionKey?: string;
}
| {
deliveryMode: "target";
to: string;
accountId?: string;
agentId?: string;
sessionKey?: string;
};
type SignalApprovalReactionTarget = ApprovalReactionTargetRecord<SignalApprovalReactionRoute> & {
approvalKind: ApprovalKind;
targetAuthorKeys: readonly string[];
route: SignalApprovalReactionRoute;
};
type SignalApprovalDeliveryTarget = {
channel: string;
to: string;
accountId?: string | null;
};
type SignalApprovalDeliveryResult = {
channel?: string;
messageId?: string | null;
toJid?: string;
meta?: Record<string, unknown>;
};
const resolverRuntimeLoader = createLazyRuntimeModule(() => import("./approval-resolver.js"));
const signalApprovalReactionTargets =
createApprovalReactionTargetStore<SignalApprovalReactionTarget>({
namespace: PERSISTENT_NAMESPACE,
maxEntries: PERSISTENT_MAX_ENTRIES,
defaultTtlMs: DEFAULT_REACTION_TARGET_TTL_MS,
openStore: (storeParams) => getOptionalSignalRuntime()?.state.openKeyedStore(storeParams),
logPersistentError: reportPersistentApprovalReactionError,
readPersistedTarget,
});
const loadApprovalResolver = resolverRuntimeLoader;
function resolveApprovalKindFromId(approvalId: string): ApprovalKind {
return approvalId.startsWith("plugin:") ? "plugin" : "exec";
}
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 ?? "session";
}
function approvalModeIncludesSession(mode: ApprovalForwardingMode): boolean {
return mode === "session" || mode === "both";
}
function approvalModeIncludesTargets(mode: ApprovalForwardingMode): boolean {
return mode === "targets" || mode === "both";
}
function matchesSignalApprovalReactionFilters(params: {
config: ApprovalForwardingConfig;
route: Pick<SignalApprovalReactionRoute, "agentId" | "sessionKey">;
}): boolean {
return matchesApprovalRequestFilters({
request: {
agentId: params.route.agentId,
sessionKey: params.route.sessionKey,
},
agentFilter: params.config.agentFilter,
sessionFilter: params.config.sessionFilter,
fallbackAgentIdFromSessionKey: true,
});
}
function targetAccountMatches(params: {
routeAccountId?: string | null;
configuredAccountId?: string | null;
}): boolean {
const configuredAccountId = normalizeOptionalString(params.configuredAccountId);
if (!configuredAccountId) {
return true;
}
const routeAccountId = normalizeOptionalString(params.routeAccountId);
return Boolean(
routeAccountId &&
normalizeAccountId(routeAccountId) === normalizeAccountId(configuredAccountId),
);
}
function resolveSignalApprovalRouteTarget(params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
}): string | null {
try {
return (
resolveSignalTarget({
cfg: params.cfg,
accountId: params.accountId,
input: params.to,
})?.to ??
normalizeSignalMessagingTarget(params.to) ??
null
);
} catch {
return null;
}
}
function hasMatchingSignalApprovalReactionTarget(params: {
cfg: OpenClawConfig;
config: ApprovalForwardingConfig;
route: Extract<SignalApprovalReactionRoute, { deliveryMode: "target" }>;
}): boolean {
return (params.config.targets ?? []).some((target) => {
if (normalizeLowercaseStringOrEmpty(target.channel) !== "signal") {
return false;
}
const configuredTo = resolveSignalApprovalRouteTarget({
cfg: params.cfg,
accountId: target.accountId ?? params.route.accountId,
to: target.to,
});
if (!configuredTo || configuredTo !== params.route.to) {
return false;
}
return targetAccountMatches({
routeAccountId: params.route.accountId,
configuredAccountId: target.accountId,
});
});
}
function isSignalApprovalReactionRouteStillEnabled(params: {
cfg: OpenClawConfig;
target: Pick<SignalApprovalReactionTarget, "approvalKind" | "route">;
}): boolean {
const config = resolveApprovalForwardingConfig({
cfg: params.cfg,
approvalKind: params.target.approvalKind,
});
if (!config?.enabled) {
return false;
}
const mode = normalizeApprovalForwardingMode(config.mode);
if (params.target.route.deliveryMode === "target") {
return (
approvalModeIncludesTargets(mode) &&
matchesSignalApprovalReactionFilters({ config, route: params.target.route }) &&
hasMatchingSignalApprovalReactionTarget({
cfg: params.cfg,
config,
route: params.target.route,
})
);
}
if (!approvalModeIncludesSession(mode)) {
return false;
}
return matchesSignalApprovalReactionFilters({ config, route: params.target.route });
}
export function resolveSignalApprovalConversationKey(to: string): string | null {
return normalizeSignalMessagingTarget(to) ?? null;
}
function resolveSignalApprovalConversationKeyForDeliveredTarget(params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
}): string | null {
try {
return (
resolveSignalTarget({
cfg: params.cfg,
accountId: params.accountId,
input: params.to,
})?.to ?? resolveSignalApprovalConversationKey(params.to)
);
} catch {
return resolveSignalApprovalConversationKey(params.to);
}
}
function normalizeSignalApprovalTargetAuthorKey(value: string): string | null {
const normalized = normalizeOptionalString(value);
if (!normalized) {
return null;
}
const withoutSignalPrefix = normalized.replace(/^signal:/i, "").trim();
const lower = normalizeLowercaseStringOrEmpty(withoutSignalPrefix);
if (lower.startsWith("uuid:")) {
const uuid = withoutSignalPrefix.slice("uuid:".length).trim().toLowerCase();
return uuid ? `uuid:${uuid}` : null;
}
if (looksLikeUuid(withoutSignalPrefix)) {
return `uuid:${withoutSignalPrefix.toLowerCase()}`;
}
return normalizeE164(withoutSignalPrefix);
}
export function resolveSignalApprovalTargetAuthorKeys(params: {
targetAuthor?: string | null;
targetAuthorUuid?: string | null;
}): string[] {
const targetAuthorUuid = normalizeOptionalString(params.targetAuthorUuid);
const keys = [
targetAuthorUuid
? `uuid:${targetAuthorUuid
.replace(/^uuid:/i, "")
.trim()
.toLowerCase()}`
: null,
params.targetAuthor ? normalizeSignalApprovalTargetAuthorKey(params.targetAuthor) : null,
].filter((key): key is string => Boolean(key));
return Array.from(new Set(keys));
}
function buildReactionTargetKey(params: {
accountId: string;
conversationKey: string;
messageId: string;
}) {
const accountId = params.accountId.trim();
const conversationKey = params.conversationKey.trim();
const messageId = params.messageId.trim();
if (!accountId || !conversationKey || !messageId || messageId === "unknown") {
return null;
}
return `${accountId}:${conversationKey}:${messageId}`;
}
function reportPersistentApprovalReactionError(error: unknown): void {
try {
getOptionalSignalRuntime()
?.logging.getChildLogger({ plugin: "signal", feature: "approval-reaction-state" })
.warn("Signal persistent approval reaction state failed", { error: String(error) });
} catch {
// Best effort only: persistent state must never break Signal reactions.
}
}
function readPersistedTarget(target: unknown): SignalApprovalReactionTarget | null {
const value = target as Partial<SignalApprovalReactionTarget> | null | undefined;
if (
!value ||
typeof value.approvalId !== "string" ||
(value.approvalKind !== "exec" && value.approvalKind !== "plugin") ||
!value.route ||
(value.route.deliveryMode !== "session" && value.route.deliveryMode !== "target") ||
!Array.isArray(value.targetAuthorKeys) ||
!Array.isArray(value.allowedDecisions)
) {
return null;
}
const targetRouteTo =
value.route.deliveryMode === "target" && typeof value.route.to === "string"
? normalizeSignalMessagingTarget(value.route.to)
: null;
if (value.route.deliveryMode === "target" && !targetRouteTo) {
return null;
}
const route: SignalApprovalReactionRoute =
value.route.deliveryMode === "target"
? {
deliveryMode: "target",
to: targetRouteTo!,
...(typeof value.route.accountId === "string"
? { accountId: value.route.accountId }
: {}),
...(typeof value.route.agentId === "string" ? { agentId: value.route.agentId } : {}),
...(typeof value.route.sessionKey === "string"
? { sessionKey: value.route.sessionKey }
: {}),
}
: {
deliveryMode: "session",
...(typeof value.route.agentId === "string" ? { agentId: value.route.agentId } : {}),
...(typeof value.route.sessionKey === "string"
? { sessionKey: value.route.sessionKey }
: {}),
};
return {
approvalId: value.approvalId,
approvalKind: value.approvalKind,
allowedDecisions: value.allowedDecisions,
targetAuthorKeys: value.targetAuthorKeys,
route,
};
}
export function listSignalApprovalReactionBindings(
allowedDecisions: readonly ExecApprovalReplyDecision[],
): SignalApprovalReactionBinding[] {
return listApprovalReactionBindings({ allowedDecisions });
}
export function buildSignalApprovalReactionHint(
allowedDecisions: readonly ExecApprovalReplyDecision[],
): string | null {
return buildApprovalReactionHint({ allowedDecisions });
}
export function addSignalApprovalReactionHintToText(params: {
text: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
}): string {
return addApprovalReactionHintToText(params);
}
function buildTargetRoute(params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
approvalId: string;
approvalKind?: ApprovalKind;
agentId?: string | null;
sessionKey?: string | null;
}): Extract<SignalApprovalReactionRoute, { deliveryMode: "target" }> | null {
const to = resolveSignalApprovalRouteTarget({
cfg: params.cfg,
accountId: params.accountId,
to: params.to,
});
if (!to) {
return null;
}
const route: Extract<SignalApprovalReactionRoute, { deliveryMode: "target" }> = {
deliveryMode: "target",
to,
...(normalizeOptionalString(params.accountId)
? { accountId: normalizeOptionalString(params.accountId) }
: {}),
...(normalizeOptionalString(params.agentId)
? { agentId: normalizeOptionalString(params.agentId) }
: {}),
...(normalizeOptionalString(params.sessionKey)
? { sessionKey: normalizeOptionalString(params.sessionKey) }
: {}),
};
return isSignalApprovalReactionRouteStillEnabled({
cfg: params.cfg,
target: {
approvalKind: params.approvalKind ?? resolveApprovalKindFromId(params.approvalId),
route,
},
})
? route
: null;
}
export function hasSignalApprovalReactionApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
return getSignalApprovalApprovers(params).length > 0;
}
export function registerSignalApprovalReactionTarget(params: {
accountId: string;
conversationKey: string;
messageId: string;
approvalId: string;
approvalKind?: ApprovalKind;
allowedDecisions: readonly ExecApprovalReplyDecision[];
targetAuthorKeys: readonly string[];
route: SignalApprovalReactionRoute;
routeAllowed: boolean;
ttlMs?: number;
}): SignalApprovalReactionTarget | null {
const key = buildReactionTargetKey(params);
const approvalId = params.approvalId.trim();
const targetAuthorKeys = Array.from(
new Set(
params.targetAuthorKeys
.map((entry) => normalizeSignalApprovalTargetAuthorKey(entry))
.filter((entry): entry is string => Boolean(entry)),
),
);
const allowedDecisions = listSignalApprovalReactionBindings(params.allowedDecisions).map(
(binding) => binding.decision,
);
if (!params.routeAllowed || !key || !approvalId || allowedDecisions.length === 0) {
return null;
}
if (targetAuthorKeys.length === 0) {
return null;
}
const route =
params.route.deliveryMode === "target"
? ({
deliveryMode: "target",
to: params.route.to,
...(normalizeOptionalString(params.route.accountId)
? { accountId: normalizeOptionalString(params.route.accountId) }
: {}),
...(normalizeOptionalString(params.route.agentId)
? { agentId: normalizeOptionalString(params.route.agentId) }
: {}),
...(normalizeOptionalString(params.route.sessionKey)
? { sessionKey: normalizeOptionalString(params.route.sessionKey) }
: {}),
} satisfies SignalApprovalReactionRoute)
: ({
deliveryMode: "session",
...(normalizeOptionalString(params.route.agentId)
? { agentId: normalizeOptionalString(params.route.agentId) }
: {}),
...(normalizeOptionalString(params.route.sessionKey)
? { sessionKey: normalizeOptionalString(params.route.sessionKey) }
: {}),
} satisfies SignalApprovalReactionRoute);
const target: SignalApprovalReactionTarget = {
approvalId,
approvalKind: params.approvalKind ?? resolveApprovalKindFromId(approvalId),
allowedDecisions,
targetAuthorKeys,
route,
};
signalApprovalReactionTargets.register(key, target, { ttlMs: params.ttlMs });
return target;
}
export function addSignalApprovalReactionHintToStructuredPayload(params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
payload: ReplyPayload;
targetAuthor?: string | null;
targetAuthorUuid?: string | null;
}): ReplyPayload | null {
const metadata = getExecApprovalReplyMetadata(params.payload);
if (!metadata?.allowedDecisions || metadata.allowedDecisions.length === 0) {
return null;
}
if (resolveSignalApprovalTargetAuthorKeys(params).length === 0) {
return null;
}
if (!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.accountId })) {
return null;
}
const route = buildTargetRoute({
cfg: params.cfg,
accountId: params.accountId,
to: params.to,
approvalId: metadata.approvalId,
approvalKind: metadata.approvalKind,
agentId: metadata.agentId,
sessionKey: metadata.sessionKey,
});
if (!route || !params.payload.text) {
return null;
}
return {
...params.payload,
text: addSignalApprovalReactionHintToText({
text: params.payload.text,
allowedDecisions: metadata.allowedDecisions,
}),
};
}
function readSignalDeliveryVisibleText(result: SignalApprovalDeliveryResult): string | null {
const meta = result.meta;
const visibleText = meta?.signalVisibleText ?? meta?.visibleText;
return typeof visibleText === "string" ? visibleText : null;
}
function listDeliveredSignalMessageIdsWithVisibleHint(params: {
payload: ReplyPayload;
results: readonly SignalApprovalDeliveryResult[];
}): string[] {
const signalResults = params.results.filter(
(result) => !result.channel || normalizeLowercaseStringOrEmpty(result.channel) === "signal",
);
const resultsWithVisibleText = signalResults.filter(
(result) => readSignalDeliveryVisibleText(result) !== null,
);
const candidates = resultsWithVisibleText.length > 0 ? resultsWithVisibleText : signalResults;
if (resultsWithVisibleText.length === 0 && candidates.length !== 1) {
return [];
}
const ids = candidates
.filter((result) =>
resultsWithVisibleText.length > 0
? hasApprovalReactionHintText(readSignalDeliveryVisibleText(result))
: hasApprovalReactionHintText(params.payload.text),
)
.map((result) => normalizeOptionalString(result.messageId))
.filter((messageId): messageId is string => Boolean(messageId && messageId !== "unknown"));
return Array.from(new Set(ids));
}
export function registerSignalApprovalReactionTargetForDeliveredPayload(params: {
cfg: OpenClawConfig;
target: SignalApprovalDeliveryTarget;
payload: ReplyPayload;
results: readonly SignalApprovalDeliveryResult[];
targetAuthor?: string | null;
targetAuthorUuid?: string | null;
ttlMs?: number;
}): boolean {
if (normalizeLowercaseStringOrEmpty(params.target.channel) !== "signal") {
return false;
}
const metadata = getExecApprovalReplyMetadata(params.payload);
if (!metadata?.allowedDecisions || metadata.allowedDecisions.length === 0) {
return false;
}
if (!hasApprovalReactionHintText(params.payload.text)) {
return false;
}
if (
!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.target.accountId })
) {
return false;
}
const conversationKey = resolveSignalApprovalConversationKeyForDeliveredTarget({
cfg: params.cfg,
accountId: params.target.accountId,
to: params.target.to,
});
if (!conversationKey) {
return false;
}
const route = buildTargetRoute({
cfg: params.cfg,
accountId: params.target.accountId,
to: params.target.to,
approvalId: metadata.approvalId,
approvalKind: metadata.approvalKind,
agentId: metadata.agentId,
sessionKey: metadata.sessionKey,
});
if (!route) {
return false;
}
const targetAuthorKeys = resolveSignalApprovalTargetAuthorKeys(params);
if (targetAuthorKeys.length === 0) {
return false;
}
let registered = false;
for (const messageId of listDeliveredSignalMessageIdsWithVisibleHint({
payload: params.payload,
results: params.results,
})) {
registered =
Boolean(
registerSignalApprovalReactionTarget({
accountId: normalizeAccountId(params.target.accountId ?? undefined),
conversationKey,
messageId,
approvalId: metadata.approvalId,
approvalKind: metadata.approvalKind,
allowedDecisions: metadata.allowedDecisions,
targetAuthorKeys,
route,
routeAllowed: true,
ttlMs: params.ttlMs,
}),
) || registered;
}
return registered;
}
export function unregisterSignalApprovalReactionTarget(params: {
accountId: string;
conversationKey: string;
messageId: string;
}): void {
const key = buildReactionTargetKey(params);
if (!key) {
return;
}
signalApprovalReactionTargets.delete(key);
}
function resolveTarget(params: {
target: SignalApprovalReactionTarget | null | undefined;
reactionKey: string;
targetAuthorKeys: readonly string[];
}): SignalApprovalReactionResolution | null {
const target = params.target;
if (!target) {
return null;
}
if (
params.targetAuthorKeys.length === 0 ||
!params.targetAuthorKeys.some((key) => target.targetAuthorKeys.includes(key))
) {
return null;
}
const resolved = resolveApprovalReactionTarget<SignalApprovalReactionRoute>({
target,
reactionKey: params.reactionKey,
});
if (!resolved?.route) {
return null;
}
return {
approvalId: resolved.approvalId,
approvalKind: resolved.approvalKind,
decision: resolved.decision,
route: resolved.route,
};
}
export async function resolveSignalApprovalReactionTargetWithPersistence(params: {
accountId: string;
conversationKey: string;
messageId: string;
reactionKey: string;
targetAuthor?: string | null;
targetAuthorUuid?: string | null;
}): Promise<SignalApprovalReactionResolution | null> {
const key = buildReactionTargetKey(params);
if (!key) {
return null;
}
const targetAuthorKeys = resolveSignalApprovalTargetAuthorKeys(params);
if (targetAuthorKeys.length === 0) {
return null;
}
return resolveTarget({
target: await signalApprovalReactionTargets.lookup(key),
reactionKey: params.reactionKey,
targetAuthorKeys,
});
}
export async function maybeResolveSignalApprovalReaction(params: {
cfg: OpenClawConfig;
accountId: string;
conversationKey: string;
messageId: string;
reactionKey: string;
actorId?: string | null;
targetAuthor?: string | null;
targetAuthorUuid?: string | null;
gatewayUrl?: string;
logVerboseMessage?: (message: string) => void;
}): Promise<boolean> {
const target = await resolveSignalApprovalReactionTargetWithPersistence({
accountId: params.accountId,
conversationKey: params.conversationKey,
messageId: params.messageId,
reactionKey: params.reactionKey,
targetAuthor: params.targetAuthor,
targetAuthorUuid: params.targetAuthorUuid,
});
if (!target) {
return false;
}
if (!isSignalApprovalReactionRouteStillEnabled({ cfg: params.cfg, target })) {
params.logVerboseMessage?.(
`signal: approval reaction denied id=${target.approvalId}; approval route is no longer enabled`,
);
return true;
}
const actorId = params.actorId?.trim();
if (!actorId) {
params.logVerboseMessage?.(
`signal: approval reaction ignored for ${target.approvalId}; missing actor identity`,
);
return true;
}
const approvers = getSignalApprovalApprovers({ cfg: params.cfg, accountId: params.accountId });
if (approvers.length === 0) {
params.logVerboseMessage?.(
`signal: approval reaction denied id=${target.approvalId}; reactions require explicit approvers`,
);
return true;
}
const auth = signalApprovalAuth.authorizeActorAction({
cfg: params.cfg,
accountId: params.accountId,
senderId: actorId,
action: "approve",
approvalKind: target.approvalKind,
});
if (!auth.authorized) {
params.logVerboseMessage?.(
`signal: approval reaction denied id=${target.approvalId} sender=${actorId}`,
);
return true;
}
const { isApprovalNotFoundError, resolveSignalApproval } = await loadApprovalResolver();
try {
await resolveSignalApproval({
cfg: params.cfg,
approvalId: target.approvalId,
decision: target.decision,
senderId: actorId,
gatewayUrl: params.gatewayUrl,
});
params.logVerboseMessage?.(
`signal: approval reaction resolved id=${target.approvalId} sender=${actorId} decision=${target.decision}`,
);
return true;
} catch (error) {
if (isApprovalNotFoundError(error)) {
unregisterSignalApprovalReactionTarget({
accountId: params.accountId,
conversationKey: params.conversationKey,
messageId: params.messageId,
});
params.logVerboseMessage?.(
`signal: approval reaction ignored for expired approval id=${target.approvalId} sender=${actorId}`,
);
return true;
}
params.logVerboseMessage?.(
`signal: approval reaction failed id=${target.approvalId} sender=${actorId}: ${String(error)}`,
);
return true;
}
}
export function clearSignalApprovalReactionTargetsForTest(): void {
signalApprovalReactionTargets.clearForTest();
resolverRuntimeLoader.clear();
}