mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
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
332 lines
9.7 KiB
TypeScript
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)}`);
|
|
},
|
|
},
|
|
});
|