// Slack plugin module implements approval handler behavior. import type { App } from "@slack/bolt"; import type { Block, KnownBlock } from "@slack/web-api"; import type { ChannelApprovalCapabilityHandlerContext, ExecApprovalExpiredView, ExecApprovalPendingView, ExecApprovalResolvedView, ExpiredApprovalView, PendingApprovalView, PluginApprovalExpiredView, PluginApprovalPendingView, PluginApprovalResolvedView, 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 { buildApprovalPresentationFromActionDescriptors } from "openclaw/plugin-sdk/approval-reply-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { logError } from "openclaw/plugin-sdk/logging-core"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isSlackAnyNativeApprovalClientEnabled, resolveSlackApprovalKind, shouldHandleSlackNativeApprovalRequest, } from "./approval-native-gates.js"; import { normalizeSlackApproverId } from "./exec-approvals.js"; import { resolveSlackReplyBlocks } from "./reply-blocks.js"; import { sendMessageSlack } from "./send.js"; import { truncateSlackText } from "./truncate.js"; type SlackBlock = Block | KnownBlock; type SlackPendingApproval = { channelId: string; messageTs: string; }; type SlackPendingDelivery = { text: string; blocks: SlackBlock[]; }; type SlackMetadataItem = { label: string; value: string; }; type SlackPluginApprovalView = | PluginApprovalPendingView | PluginApprovalResolvedView | PluginApprovalExpiredView; const SLACK_CONTEXT_ELEMENTS_MAX = 10; const SLACK_CHAT_UPDATE_TEXT_LIMIT = 4000; const SLACK_TEXT_OBJECT_MAX = 3000; 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 SlackMetadataItem[]): string[] { const lines: string[] = []; for (const item of metadata) { lines.push(formatSlackMetadataLine(item.label, item.value)); } return lines; } function buildSlackMetadataContextElements(metadata: readonly SlackMetadataItem[]) { const lines = buildSlackMetadataLines(metadata); const visibleLineCount = lines.length > SLACK_CONTEXT_ELEMENTS_MAX ? SLACK_CONTEXT_ELEMENTS_MAX - 1 : lines.length; const elements: Array<{ type: "mrkdwn"; text: string }> = []; for (let index = 0; index < visibleLineCount; index += 1) { const line = lines[index]; if (line === undefined) { continue; } elements.push({ type: "mrkdwn", text: truncateSlackMrkdwn(line, SLACK_TEXT_OBJECT_MAX), }); } if (lines.length > SLACK_CONTEXT_ELEMENTS_MAX) { elements.push({ type: "mrkdwn", text: `…+${lines.length - visibleLineCount} more`, }); } return elements; } function buildSlackMetadataContextBlocks(metadata: readonly SlackMetadataItem[]): SlackBlock[] { const metadataElements = buildSlackMetadataContextElements(metadata); return metadataElements.length > 0 ? [ { type: "context", elements: metadataElements, } satisfies SlackBlock, ] : []; } function resolveSlackApprovalDecisionLabel( decision: "allow-once" | "allow-always" | "deny", ): string { return decision === "allow-once" ? "Allowed once" : decision === "allow-always" ? "Allowed always" : "Denied"; } function buildSlackPluginMetadata(view: SlackPluginApprovalView): SlackMetadataItem[] { return [{ label: "Approval ID", value: view.approvalId }, ...view.metadata]; } function resolveSlackPluginDescription(view: SlackPluginApprovalView): string { return normalizeOptionalString(view.description) ?? "A plugin action needs your approval."; } function buildSlackPluginRequestBlocks(view: SlackPluginApprovalView): SlackBlock[] { return [ { type: "section", text: { type: "mrkdwn", text: `*Request*\n${truncateSlackMrkdwn(view.title, 2600)}`, }, }, ...buildSlackMetadataContextBlocks(buildSlackPluginMetadata(view)), ]; } function buildSlackExecPendingApprovalText(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.join("\n"); } function buildSlackPluginPendingApprovalText(view: PluginApprovalPendingView): string { const metadataLines = buildSlackMetadataLines(buildSlackPluginMetadata(view)); const lines = [ "*Plugin approval required*", resolveSlackPluginDescription(view), "", "*Request*", view.title, ...metadataLines, ]; return lines.join("\n"); } function buildSlackPendingApprovalText(view: PendingApprovalView): string { return view.approvalKind === "plugin" ? buildSlackPluginPendingApprovalText(view) : buildSlackExecPendingApprovalText(view); } function buildSlackExecPendingApprovalBlocks(view: ExecApprovalPendingView): SlackBlock[] { const interactiveBlocks = resolveSlackReplyBlocks({ text: "", presentation: buildApprovalPresentationFromActionDescriptors(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))}`, }, }, ...buildSlackMetadataContextBlocks(view.metadata), ...interactiveBlocks, ]; } function buildSlackPluginPendingApprovalBlocks(view: PluginApprovalPendingView): SlackBlock[] { const interactiveBlocks = resolveSlackReplyBlocks({ text: "", presentation: buildApprovalPresentationFromActionDescriptors(view.actions), }) ?? []; return [ { type: "section", text: { type: "mrkdwn", text: `*Plugin approval required*\n${truncateSlackMrkdwn( resolveSlackPluginDescription(view), 2600, )}`, }, }, ...buildSlackPluginRequestBlocks(view), ...interactiveBlocks, ]; } function buildSlackPendingApprovalBlocks(view: PendingApprovalView): SlackBlock[] { return view.approvalKind === "plugin" ? buildSlackPluginPendingApprovalBlocks(view) : buildSlackExecPendingApprovalBlocks(view); } function buildSlackExecResolvedText(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 buildSlackPluginResolvedText(view: PluginApprovalResolvedView): string { const resolvedBy = formatSlackApprover(view.resolvedBy); const metadataLines = buildSlackMetadataLines(buildSlackPluginMetadata(view)); const lines = [ `*Plugin approval: ${resolveSlackApprovalDecisionLabel(view.decision)}*`, resolvedBy ? `Resolved by ${resolvedBy}.` : "Resolved.", "", "*Request*", view.title, ...metadataLines, ]; return lines.join("\n"); } function buildSlackResolvedText(view: ResolvedApprovalView): string { return view.approvalKind === "plugin" ? buildSlackPluginResolvedText(view) : buildSlackExecResolvedText(view); } function buildSlackExecResolvedBlocks(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 buildSlackPluginResolvedBlocks(view: PluginApprovalResolvedView): SlackBlock[] { const resolvedBy = formatSlackApprover(view.resolvedBy); return [ { type: "section", text: { type: "mrkdwn", text: `*Plugin approval: ${resolveSlackApprovalDecisionLabel(view.decision)}*\n${ resolvedBy ? `Resolved by ${resolvedBy}.` : "Resolved." }`, }, }, ...buildSlackPluginRequestBlocks(view), ]; } function buildSlackResolvedBlocks(view: ResolvedApprovalView): SlackBlock[] { return view.approvalKind === "plugin" ? buildSlackPluginResolvedBlocks(view) : buildSlackExecResolvedBlocks(view); } function buildSlackExecExpiredText(view: ExecApprovalExpiredView): string { return [ "*Exec approval expired*", "This approval request expired before it was resolved.", "", "*Command*", buildSlackCodeBlock(view.commandText), ].join("\n"); } function buildSlackPluginExpiredText(view: PluginApprovalExpiredView): string { const metadataLines = buildSlackMetadataLines(buildSlackPluginMetadata(view)); return [ "*Plugin approval expired*", "This approval request expired before it was resolved.", "", "*Request*", view.title, ...metadataLines, ].join("\n"); } function buildSlackExpiredText(view: ExpiredApprovalView): string { return view.approvalKind === "plugin" ? buildSlackPluginExpiredText(view) : buildSlackExecExpiredText(view); } function buildSlackExecExpiredBlocks(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))}`, }, }, ]; } function buildSlackPluginExpiredBlocks(view: PluginApprovalExpiredView): SlackBlock[] { return [ { type: "section", text: { type: "mrkdwn", text: "*Plugin approval expired*\nThis approval request expired before it was resolved.", }, }, ...buildSlackPluginRequestBlocks(view), ]; } function buildSlackExpiredBlocks(view: ExpiredApprovalView): SlackBlock[] { return view.approvalKind === "plugin" ? buildSlackPluginExpiredBlocks(view) : buildSlackExecExpiredBlocks(view); } 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: truncateSlackText(params.text, SLACK_CHAT_UPDATE_TEXT_LIMIT), blocks: params.blocks, }); } catch (err) { logError(`slack approvals: failed to update message: ${String(err)}`); } } export const slackApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter< SlackPendingDelivery, { to: string; threadTs?: string }, SlackPendingApproval, never, SlackPendingDelivery >({ eventKinds: ["exec", "plugin"], availability: { isConfigured: (params) => { const resolved = resolveHandlerContext(params); return resolved ? isSlackAnyNativeApprovalClientEnabled({ cfg: params.cfg, accountId: resolved.accountId, }) : false; }, shouldHandle: (params) => { const resolved = resolveHandlerContext(params); if (!resolved) { return false; } return shouldHandleSlackNativeApprovalRequest({ cfg: params.cfg, accountId: resolved.accountId, approvalKind: resolveSlackApprovalKind(params.request), request: params.request, }); }, }, presentation: { buildPendingPayload: ({ view }) => ({ text: buildSlackPendingApprovalText(view), blocks: buildSlackPendingApprovalBlocks(view), }), buildResolvedResult: ({ view }) => ({ kind: "update", payload: { text: buildSlackResolvedText(view), blocks: buildSlackResolvedBlocks(view), }, }), buildExpiredResult: ({ view }) => ({ kind: "update", payload: { text: buildSlackExpiredText(view), blocks: buildSlackExpiredBlocks(view), }, }), }, 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 approvals: failed to deliver approval ${request.id}: ${String(error)}`); }, }, });