import { setTimeout as sleep } from "node:timers/promises"; import type { ChannelApprovalCapabilityHandlerContext, PendingApprovalView, ResolvedApprovalView, } from "openclaw/plugin-sdk/approval-handler-runtime"; import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime"; import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime"; import { buildExecApprovalPendingReplyPayload, buildPluginApprovalPendingReplyPayload, type ExecApprovalReplyDecision, } from "openclaw/plugin-sdk/approval-reply-runtime"; import { buildPluginApprovalResolvedReplyPayload } from "openclaw/plugin-sdk/approval-runtime"; import type { ExecApprovalRequest, PluginApprovalRequest, } from "openclaw/plugin-sdk/approval-runtime"; import { listMessageReceiptPlatformIds, resolveMessageReceiptPrimaryId, } from "openclaw/plugin-sdk/channel-message"; import { buildMatrixApprovalReactionHint, listMatrixApprovalReactionBindings, registerMatrixApprovalReactionTarget, unregisterMatrixApprovalReactionTarget, } from "./approval-reactions.js"; import { isMatrixAnyApprovalClientEnabled, shouldHandleMatrixApprovalRequest, } from "./exec-approvals.js"; import { resolveMatrixAccount } from "./matrix/accounts.js"; import { deleteMatrixMessage, editMatrixMessage } from "./matrix/actions/messages.js"; import { repairMatrixDirectRooms } from "./matrix/direct-management.js"; import type { MatrixClient } from "./matrix/sdk.js"; import { reactMatrixMessage, sendMessageMatrix, sendSingleTextMessageMatrix, } from "./matrix/send.js"; import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js"; import type { CoreConfig } from "./types.js"; // OpenClaw Matrix custom event content for capable clients; body and reactions remain fallback. const MATRIX_APPROVAL_METADATA_KEY = "com.openclaw.approval" as const; type PendingMessage = { roomId: string; platformMessageIds: readonly string[]; reactionEventId: string; }; type PreparedMatrixTarget = { to: string; roomId: string; threadId?: string; }; type MatrixApprovalMetadataAction = { decision: ExecApprovalReplyDecision; label: string; style: PendingApprovalView["actions"][number]["style"]; command: string; }; type MatrixApprovalMetadataBase = { version: 1; type: "approval.request"; id: string; state: "pending"; kind: PendingApprovalView["approvalKind"]; phase: "pending"; title: string; description?: string; expiresAtMs: number; metadata: PendingApprovalView["metadata"]; allowedDecisions: ExecApprovalReplyDecision[]; actions: MatrixApprovalMetadataAction[]; }; type MatrixExecApprovalMetadata = MatrixApprovalMetadataBase & { kind: "exec"; ask?: string; agentId?: string; commandText: string; commandPreview?: string; cwd?: string; envKeys?: readonly string[]; host?: string; nodeId?: string; sessionKey?: string; }; type MatrixPluginApprovalSeverity = Extract< PendingApprovalView, { approvalKind: "plugin" } >["severity"]; type MatrixPluginApprovalMetadata = MatrixApprovalMetadataBase & { kind: "plugin"; agentId?: string; pluginId?: string; toolName?: string; severity: MatrixPluginApprovalSeverity; }; type MatrixApprovalMetadata = MatrixExecApprovalMetadata | MatrixPluginApprovalMetadata; type MatrixApprovalExtraContent = { [MATRIX_APPROVAL_METADATA_KEY]: MatrixApprovalMetadata; }; type PendingApprovalContent = { approvalId: string; text: string; allowedDecisions: readonly ExecApprovalReplyDecision[]; extraContent: MatrixApprovalExtraContent; }; type ReactionTargetRef = { roomId: string; eventId: string; }; type MatrixRawApprovalTarget = { to: string; threadId?: string | number | null; }; type MatrixPrepareTargetParams = { cfg: CoreConfig; accountId?: string | null; gatewayUrl?: string; context?: unknown; rawTarget: MatrixRawApprovalTarget; }; const MATRIX_APPROVAL_DELIVERY_ATTEMPTS = 3; const MATRIX_APPROVAL_DELIVERY_RETRY_DELAY_MS = 250; export type MatrixApprovalHandlerDeps = { nowMs?: () => number; sendMessage?: typeof sendMessageMatrix; sendSingleTextMessage?: typeof sendSingleTextMessageMatrix; reactMessage?: typeof reactMatrixMessage; editMessage?: typeof editMatrixMessage; deleteMessage?: typeof deleteMatrixMessage; repairDirectRooms?: typeof repairMatrixDirectRooms; }; export type MatrixApprovalHandlerContext = { client: MatrixClient; deps?: MatrixApprovalHandlerDeps; }; function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): { accountId: string; context: MatrixApprovalHandlerContext; } | null { const context = params.context as MatrixApprovalHandlerContext | undefined; const accountId = params.accountId?.trim() || ""; if (!context?.client || !accountId) { return null; } return { accountId, context }; } function normalizePendingMessageIds(entry: PendingMessage): string[] { return Array.from( new Set(entry.platformMessageIds.map((messageId) => messageId.trim()).filter(Boolean)), ); } function normalizeReactionTargetRef(params: ReactionTargetRef): ReactionTargetRef | null { const roomId = params.roomId.trim(); const eventId = params.eventId.trim(); if (!roomId || !eventId) { return null; } return { roomId, eventId }; } function normalizeThreadId(value?: string | number | null): string | undefined { const trimmed = value == null ? "" : String(value).trim(); return trimmed || undefined; } function isSingleMatrixMessageLimitError(error: unknown): boolean { return ( error instanceof Error && error.message.includes("Matrix single-message text exceeds limit") ); } async function retryMatrixApprovalDelivery( operation: () => Promise, params: { shouldRetry?: (error: unknown) => boolean } = {}, ): Promise { let lastError: unknown; for (let attempt = 1; attempt <= MATRIX_APPROVAL_DELIVERY_ATTEMPTS; attempt += 1) { try { return await operation(); } catch (error) { lastError = error; if (attempt === MATRIX_APPROVAL_DELIVERY_ATTEMPTS || params.shouldRetry?.(error) === false) { break; } await sleep(MATRIX_APPROVAL_DELIVERY_RETRY_DELAY_MS * attempt); } } throw lastError; } async function prepareTarget( params: MatrixPrepareTargetParams, ): Promise { const resolved = resolveHandlerContext(params); if (!resolved) { return null; } const target = resolveMatrixTargetIdentity(params.rawTarget.to); if (!target) { return null; } const threadId = normalizeThreadId(params.rawTarget.threadId); if (target.kind === "user") { const account = resolveMatrixAccount({ cfg: params.cfg, accountId: resolved.accountId, }); const repairDirectRooms = resolved.context.deps?.repairDirectRooms ?? repairMatrixDirectRooms; const repaired = await retryMatrixApprovalDelivery( async () => await repairDirectRooms({ client: resolved.context.client, remoteUserId: target.id, encrypted: account.config.encryption === true, }), ); if (!repaired.activeRoomId) { return null; } return { to: `room:${repaired.activeRoomId}`, roomId: repaired.activeRoomId, threadId, }; } return { to: `room:${target.id}`, roomId: target.id, threadId, }; } function buildMatrixApprovalMetadata(params: { view: PendingApprovalView; allowedDecisions: readonly ExecApprovalReplyDecision[]; }): MatrixApprovalMetadata { const base: MatrixApprovalMetadataBase = { version: 1, type: "approval.request", id: params.view.approvalId, state: "pending", kind: params.view.approvalKind, phase: params.view.phase, title: params.view.title, expiresAtMs: params.view.expiresAtMs, metadata: params.view.metadata, allowedDecisions: Array.from(params.allowedDecisions), actions: params.view.actions.map((action) => ({ decision: action.decision, label: action.label, style: action.style, command: action.command, })), ...(params.view.description != null ? { description: params.view.description } : {}), }; if (params.view.approvalKind === "plugin") { return { ...base, kind: "plugin", severity: params.view.severity, ...(params.view.agentId != null ? { agentId: params.view.agentId } : {}), ...(params.view.pluginId != null ? { pluginId: params.view.pluginId } : {}), ...(params.view.toolName != null ? { toolName: params.view.toolName } : {}), }; } return { ...base, kind: "exec", commandText: params.view.commandText, ...(params.view.ask != null ? { ask: params.view.ask } : {}), ...(params.view.agentId != null ? { agentId: params.view.agentId } : {}), ...(params.view.commandPreview != null ? { commandPreview: params.view.commandPreview } : {}), ...(params.view.cwd != null ? { cwd: params.view.cwd } : {}), ...(params.view.envKeys != null ? { envKeys: params.view.envKeys } : {}), ...(params.view.host != null ? { host: params.view.host } : {}), ...(params.view.nodeId != null ? { nodeId: params.view.nodeId } : {}), ...(params.view.sessionKey != null ? { sessionKey: params.view.sessionKey } : {}), }; } function buildPendingApprovalContent(params: { view: PendingApprovalView; nowMs: number; }): PendingApprovalContent { const allowedDecisions = params.view.actions.map((action) => action.decision); const payload = params.view.approvalKind === "plugin" ? buildPluginApprovalPendingReplyPayload({ request: { id: params.view.approvalId, request: { title: params.view.title, description: params.view.description ?? "", severity: params.view.severity, toolName: params.view.toolName ?? undefined, pluginId: params.view.pluginId ?? undefined, agentId: params.view.agentId ?? undefined, }, createdAtMs: 0, expiresAtMs: params.view.expiresAtMs, } satisfies PluginApprovalRequest, nowMs: params.nowMs, allowedDecisions, }) : buildExecApprovalPendingReplyPayload({ approvalId: params.view.approvalId, approvalSlug: params.view.approvalId.slice(0, 8), approvalCommandId: params.view.approvalId, ask: params.view.ask ?? undefined, agentId: params.view.agentId ?? undefined, allowedDecisions, command: params.view.commandText, cwd: params.view.cwd ?? undefined, host: params.view.host === "node" ? "node" : "gateway", nodeId: params.view.nodeId ?? undefined, sessionKey: params.view.sessionKey ?? undefined, expiresAtMs: params.view.expiresAtMs, nowMs: params.nowMs, }); const hint = buildMatrixApprovalReactionHint(allowedDecisions); const text = payload.text ?? ""; return { approvalId: params.view.approvalId, text: hint ? (text ? `${hint}\n\n${text}` : hint) : text, allowedDecisions, extraContent: { [MATRIX_APPROVAL_METADATA_KEY]: buildMatrixApprovalMetadata({ view: params.view, allowedDecisions, }), }, }; } function buildResolvedApprovalText(view: ResolvedApprovalView): string { if (view.approvalKind === "plugin") { return ( buildPluginApprovalResolvedReplyPayload({ resolved: { id: view.approvalId, decision: view.decision, resolvedBy: view.resolvedBy ?? undefined, ts: 0, }, }).text ?? "" ); } const decisionLabel = view.decision === "allow-once" ? "Allowed once" : view.decision === "allow-always" ? "Allowed always" : "Denied"; return [ `Exec approval: ${decisionLabel}`, "", "Command", buildMarkdownCodeBlock(view.commandText), ].join("\n"); } function buildMarkdownCodeBlock(text: string): string { const longestFence = Math.max(...Array.from(text.matchAll(/`+/g), (match) => match[0].length), 0); const fence = "`".repeat(Math.max(3, longestFence + 1)); return [fence, text, fence].join("\n"); } export const matrixApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter< PendingApprovalContent, PreparedMatrixTarget, PendingMessage, ReactionTargetRef, string >({ eventKinds: ["exec", "plugin"], availability: { isConfigured: ({ cfg, accountId, context }) => { const resolved = resolveHandlerContext({ cfg, accountId, context }); if (!resolved) { return false; } return isMatrixAnyApprovalClientEnabled({ cfg, accountId: resolved.accountId, }); }, shouldHandle: ({ cfg, accountId, request, context }) => { const resolved = resolveHandlerContext({ cfg, accountId, context }); if (!resolved) { return false; } return shouldHandleMatrixApprovalRequest({ cfg, accountId: resolved.accountId, request: request as ExecApprovalRequest | PluginApprovalRequest, }); }, }, presentation: { buildPendingPayload: ({ view, nowMs }) => buildPendingApprovalContent({ view, nowMs, }), buildResolvedResult: ({ view }) => ({ kind: "update", payload: buildResolvedApprovalText(view), }), buildExpiredResult: () => ({ kind: "delete" }), }, transport: { prepareTarget: ({ cfg, accountId, context, plannedTarget }) => { return prepareTarget({ cfg, accountId, context, rawTarget: plannedTarget.target, }).then((preparedTarget) => preparedTarget ? { dedupeKey: buildChannelApprovalNativeTargetKey({ to: preparedTarget.roomId, threadId: preparedTarget.threadId, }), target: preparedTarget, } : null, ); }, deliverPending: async ({ cfg, accountId, context, preparedTarget, pendingPayload, view }) => { const resolved = resolveHandlerContext({ cfg, accountId, context }); if (!resolved) { return null; } const sendSingleTextMessage = resolved.context.deps?.sendSingleTextMessage ?? sendSingleTextMessageMatrix; const reactMessage = resolved.context.deps?.reactMessage ?? reactMatrixMessage; let result; try { result = await retryMatrixApprovalDelivery( async () => await sendSingleTextMessage(preparedTarget.to, pendingPayload.text, { cfg: cfg as CoreConfig, accountId: resolved.accountId, client: resolved.context.client, threadId: preparedTarget.threadId, extraContent: pendingPayload.extraContent, }), { shouldRetry: (error) => !isSingleMatrixMessageLimitError(error) }, ); } catch (error) { if (!isSingleMatrixMessageLimitError(error)) { throw error; } const sendMessage = resolved.context.deps?.sendMessage ?? sendMessageMatrix; result = await retryMatrixApprovalDelivery( async () => await sendMessage(preparedTarget.to, pendingPayload.text, { cfg: cfg as CoreConfig, accountId: resolved.accountId, client: resolved.context.client, threadId: preparedTarget.threadId, extraContent: pendingPayload.extraContent, }), ); } const receiptMessageIds = listMessageReceiptPlatformIds(result.receipt); const platformMessageIds = receiptMessageIds.length ? receiptMessageIds : [result.messageId.trim()].filter(Boolean); const reactionEventId = resolveMessageReceiptPrimaryId(result.receipt) || result.primaryMessageId?.trim() || platformMessageIds[0] || result.messageId.trim(); registerMatrixApprovalReactionTarget({ roomId: result.roomId, eventId: reactionEventId, approvalId: pendingPayload.approvalId, allowedDecisions: pendingPayload.allowedDecisions, ttlMs: view.expiresAtMs - Date.now(), }); await Promise.allSettled( listMatrixApprovalReactionBindings(pendingPayload.allowedDecisions).map( async ({ emoji }) => { await reactMessage(result.roomId, reactionEventId, emoji, { cfg: cfg as CoreConfig, accountId: resolved.accountId, client: resolved.context.client, }); }, ), ); return { roomId: result.roomId, platformMessageIds, reactionEventId, }; }, updateEntry: async ({ cfg, accountId, context, entry, payload }) => { const resolved = resolveHandlerContext({ cfg, accountId, context }); if (!resolved) { return; } const editMessage = resolved.context.deps?.editMessage ?? editMatrixMessage; const deleteMessage = resolved.context.deps?.deleteMessage ?? deleteMatrixMessage; const [primaryMessageId, ...staleMessageIds] = normalizePendingMessageIds(entry); if (!primaryMessageId) { return; } const text = payload; await Promise.allSettled([ editMessage(entry.roomId, primaryMessageId, text, { cfg: cfg as CoreConfig, accountId: resolved.accountId, client: resolved.context.client, }), ...staleMessageIds.map(async (messageId) => { await deleteMessage(entry.roomId, messageId, { cfg: cfg as CoreConfig, accountId: resolved.accountId, client: resolved.context.client, reason: "approval resolved", }); }), ]); }, deleteEntry: async ({ cfg, accountId, context, entry, phase }) => { const resolved = resolveHandlerContext({ cfg, accountId, context }); if (!resolved) { return; } const deleteMessage = resolved.context.deps?.deleteMessage ?? deleteMatrixMessage; await Promise.allSettled( normalizePendingMessageIds(entry).map(async (messageId) => { await deleteMessage(entry.roomId, messageId, { cfg: cfg as CoreConfig, accountId: resolved.accountId, client: resolved.context.client, reason: phase === "expired" ? "approval expired" : "approval resolved", }); }), ); }, }, interactions: { bindPending: (params) => { const target = normalizeReactionTargetRef({ roomId: params.entry.roomId, eventId: params.entry.reactionEventId, }); if (!target) { return null; } registerMatrixApprovalReactionTarget({ roomId: target.roomId, eventId: target.eventId, approvalId: params.pendingPayload.approvalId, allowedDecisions: params.pendingPayload.allowedDecisions, ttlMs: params.view.expiresAtMs - Date.now(), }); return target; }, unbindPending: (params) => { const target = normalizeReactionTargetRef(params.binding); if (!target) { return; } unregisterMatrixApprovalReactionTarget(target); }, }, });