Files
openclaw/extensions/slack/src/approval-handler.runtime.ts
Gustavo Madeira Santana d78512b09d Refactor: centralize native approval lifecycle assembly (#62135)
Merged via squash.

Prepared head SHA: b7c20a7398
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-04-07 14:40:26 -04:00

332 lines
9.7 KiB
TypeScript

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 } from "openclaw/plugin-sdk/text-runtime";
import { slackNativeApprovalAdapter } from "./approval-native.js";
import {
isSlackExecApprovalClientEnabled,
normalizeSlackApproverId,
shouldHandleSlackExecApprovalRequest,
} 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<NonNullable<OpenClawConfig["channels"]>["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 = params.accountId?.trim() || "";
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 = resolvedBy?.trim();
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<void> {
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
>({
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,
}) &&
slackNativeApprovalAdapter.native?.describeDeliveryCapabilities({
cfg: params.cfg,
accountId: resolved.accountId,
approvalKind: "exec",
request: params.request as ExecApprovalRequest,
}).enabled === true
);
},
},
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 as SlackPendingDelivery;
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)}`);
},
},
});