import type { App } from "@slack/bolt"; import type { Block, KnownBlock } from "@slack/web-api"; import type { ChannelApprovalCapabilityHandlerContext, ExecApprovalExpiredView, ExecApprovalPendingView, ExecApprovalResolvedView, } 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 type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { buildApprovalInteractiveReplyFromActionDescriptors, type ExecApprovalRequest, } from "openclaw/plugin-sdk/infra-runtime"; import { logError, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { isSlackExecApprovalClientEnabled, shouldHandleSlackExecApprovalRequest, normalizeSlackApproverId, } from "./exec-approvals.js"; import { resolveSlackReplyBlocks } from "./reply-blocks.js"; import { sendMessageSlack } from "./send.js"; type SlackBlock = Block | KnownBlock; type SlackPendingApproval = { channelId: string; messageTs: string; }; type SlackPendingDelivery = { text: string; blocks: SlackBlock[]; }; type SlackExecApprovalConfig = NonNullable< NonNullable["slack"]>["execApprovals"] >; export type SlackApprovalHandlerContext = { app: App; config: SlackExecApprovalConfig; }; function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): { accountId: string; context: SlackApprovalHandlerContext; } | null { const context = params.context as SlackApprovalHandlerContext | undefined; const accountId = normalizeOptionalString(params.accountId) ?? ""; if (!context?.app || !accountId) { return null; } return { accountId, context }; } function truncateSlackMrkdwn(text: string, maxChars: number): string { return text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}…`; } function buildSlackCodeBlock(text: string): string { let fence = "```"; while (text.includes(fence)) { fence += "`"; } return `${fence}\n${text}\n${fence}`; } function formatSlackApprover(resolvedBy?: string | null): string | null { const normalized = resolvedBy ? normalizeSlackApproverId(resolvedBy) : undefined; if (normalized) { return `<@${normalized}>`; } const trimmed = normalizeOptionalString(resolvedBy); return trimmed ? trimmed : null; } function formatSlackMetadataLine(label: string, value: string): string { return `*${label}:* ${value}`; } function buildSlackMetadataLines(metadata: readonly { label: string; value: string }[]): string[] { return metadata.map((item) => formatSlackMetadataLine(item.label, item.value)); } function resolveSlackApprovalDecisionLabel( decision: "allow-once" | "allow-always" | "deny", ): string { return decision === "allow-once" ? "Allowed once" : decision === "allow-always" ? "Allowed always" : "Denied"; } function buildSlackPendingApprovalText(view: ExecApprovalPendingView): string { const metadataLines = buildSlackMetadataLines(view.metadata); const lines = [ "*Exec approval required*", "A command needs your approval.", "", "*Command*", buildSlackCodeBlock(view.commandText), ...metadataLines, ]; return lines.filter(Boolean).join("\n"); } function buildSlackPendingApprovalBlocks(view: ExecApprovalPendingView): SlackBlock[] { const metadataLines = buildSlackMetadataLines(view.metadata); const interactiveBlocks = resolveSlackReplyBlocks({ text: "", interactive: buildApprovalInteractiveReplyFromActionDescriptors(view.actions), }) ?? []; return [ { type: "section", text: { type: "mrkdwn", text: "*Exec approval required*\nA command needs your approval.", }, }, { type: "section", text: { type: "mrkdwn", text: `*Command*\n${buildSlackCodeBlock(truncateSlackMrkdwn(view.commandText, 2600))}`, }, }, ...(metadataLines.length > 0 ? [ { type: "context", elements: metadataLines.map((line) => ({ type: "mrkdwn" as const, text: line, })), } satisfies SlackBlock, ] : []), ...interactiveBlocks, ]; } function buildSlackResolvedText(view: ExecApprovalResolvedView): string { const resolvedBy = formatSlackApprover(view.resolvedBy); const lines = [ `*Exec approval: ${resolveSlackApprovalDecisionLabel(view.decision)}*`, resolvedBy ? `Resolved by ${resolvedBy}.` : "Resolved.", "", "*Command*", buildSlackCodeBlock(view.commandText), ]; return lines.join("\n"); } function buildSlackResolvedBlocks(view: ExecApprovalResolvedView): SlackBlock[] { const resolvedBy = formatSlackApprover(view.resolvedBy); return [ { type: "section", text: { type: "mrkdwn", text: `*Exec approval: ${resolveSlackApprovalDecisionLabel(view.decision)}*\n${ resolvedBy ? `Resolved by ${resolvedBy}.` : "Resolved." }`, }, }, { type: "section", text: { type: "mrkdwn", text: `*Command*\n${buildSlackCodeBlock(truncateSlackMrkdwn(view.commandText, 2600))}`, }, }, ]; } function buildSlackExpiredText(view: ExecApprovalExpiredView): string { return [ "*Exec approval expired*", "This approval request expired before it was resolved.", "", "*Command*", buildSlackCodeBlock(view.commandText), ].join("\n"); } function buildSlackExpiredBlocks(view: ExecApprovalExpiredView): SlackBlock[] { return [ { type: "section", text: { type: "mrkdwn", text: "*Exec approval expired*\nThis approval request expired before it was resolved.", }, }, { type: "section", text: { type: "mrkdwn", text: `*Command*\n${buildSlackCodeBlock(truncateSlackMrkdwn(view.commandText, 2600))}`, }, }, ]; } async function updateMessage(params: { app: App; channelId: string; messageTs: string; text: string; blocks: SlackBlock[]; }): Promise { try { await params.app.client.chat.update({ channel: params.channelId, ts: params.messageTs, text: params.text, blocks: params.blocks, }); } catch (err) { logError(`slack exec approvals: failed to update message: ${String(err)}`); } } export const slackApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter< SlackPendingDelivery, { to: string; threadTs?: string }, SlackPendingApproval, never, SlackPendingDelivery >({ eventKinds: ["exec"], availability: { isConfigured: (params) => { const resolved = resolveHandlerContext(params); return resolved ? isSlackExecApprovalClientEnabled({ cfg: params.cfg, accountId: resolved.accountId, }) : false; }, shouldHandle: (params) => { const resolved = resolveHandlerContext(params); if (!resolved) { return false; } return shouldHandleSlackExecApprovalRequest({ cfg: params.cfg, accountId: resolved.accountId, request: params.request as ExecApprovalRequest, }); }, }, presentation: { buildPendingPayload: ({ view }) => ({ text: buildSlackPendingApprovalText(view as ExecApprovalPendingView), blocks: buildSlackPendingApprovalBlocks(view as ExecApprovalPendingView), }), buildResolvedResult: ({ view }) => ({ kind: "update", payload: { text: buildSlackResolvedText(view as ExecApprovalResolvedView), blocks: buildSlackResolvedBlocks(view as ExecApprovalResolvedView), }, }), buildExpiredResult: ({ view }) => ({ kind: "update", payload: { text: buildSlackExpiredText(view as ExecApprovalExpiredView), blocks: buildSlackExpiredBlocks(view as ExecApprovalExpiredView), }, }), }, transport: { prepareTarget: ({ plannedTarget }) => ({ dedupeKey: buildChannelApprovalNativeTargetKey(plannedTarget.target), target: { to: plannedTarget.target.to, threadTs: plannedTarget.target.threadId != null ? String(plannedTarget.target.threadId) : undefined, }, }), deliverPending: async ({ cfg, accountId, context, preparedTarget, pendingPayload }) => { const resolved = resolveHandlerContext({ cfg, accountId, context }); if (!resolved) { return null; } const message = await sendMessageSlack(preparedTarget.to, pendingPayload.text, { cfg, accountId: resolved.accountId, threadTs: preparedTarget.threadTs, blocks: pendingPayload.blocks, client: resolved.context.app.client, }); return { channelId: message.channelId, messageTs: message.messageId, }; }, updateEntry: async ({ cfg, accountId, context, entry, payload }) => { const resolved = resolveHandlerContext({ cfg, accountId, context }); if (!resolved) { return; } const nextPayload = payload; await updateMessage({ app: resolved.context.app, channelId: entry.channelId, messageTs: entry.messageTs, text: nextPayload.text, blocks: nextPayload.blocks, }); }, }, observe: { onDeliveryError: ({ error, request }) => { logError(`slack exec approvals: failed to deliver approval ${request.id}: ${String(error)}`); }, }, });