Vendor OpenClaw source as Adolf fork baseline
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
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
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
This commit is contained in:
789
extensions/signal/src/approval-reactions.ts
Normal file
789
extensions/signal/src/approval-reactions.ts
Normal file
@@ -0,0 +1,789 @@
|
||||
// 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();
|
||||
}
|
||||
Reference in New Issue
Block a user