Files
openclaw/extensions/discord/src/approval-handler.runtime.ts
2026-04-10 09:08:28 +01:00

626 lines
20 KiB
TypeScript

import {
Button,
Row,
Separator,
TextDisplay,
serializePayload,
type MessagePayloadObject,
type TopLevelComponents,
} from "@buape/carbon";
import { ButtonStyle, Routes } from "discord-api-types/v10";
import type {
ChannelApprovalCapabilityHandlerContext,
ExecApprovalExpiredView,
ExecApprovalPendingView,
ExecApprovalResolvedView,
PendingApprovalView,
PluginApprovalExpiredView,
PluginApprovalPendingView,
PluginApprovalResolvedView,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type {
ExecApprovalActionDescriptor,
ExecApprovalDecision,
} from "openclaw/plugin-sdk/infra-runtime";
import { logDebug, logError, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { shouldHandleDiscordApprovalRequest } from "./approval-shared.js";
import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js";
import { createDiscordClient, stripUndefinedFields } from "./send.shared.js";
import { DiscordUiContainer } from "./ui.js";
type PendingApproval = {
discordMessageId: string;
discordChannelId: string;
};
type DiscordPendingDelivery = {
body: ReturnType<typeof stripUndefinedFields>;
};
type PreparedDeliveryTarget = {
discordChannelId: string;
recipientUserId?: string;
};
export type DiscordApprovalHandlerContext = {
token: string;
config: DiscordExecApprovalConfig;
};
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
accountId: string;
context: DiscordApprovalHandlerContext;
} | null {
const context = params.context as DiscordApprovalHandlerContext | undefined;
const accountId = normalizeOptionalString(params.accountId) ?? "";
if (!context?.token || !accountId) {
return null;
}
return { accountId, context };
}
class ExecApprovalContainer extends DiscordUiContainer {
constructor(params: {
cfg: OpenClawConfig;
accountId: string;
title: string;
description?: string;
commandPreview: string;
commandSecondaryPreview?: string | null;
metadataLines?: string[];
actionRow?: Row<Button>;
footer?: string;
accentColor?: string;
}) {
const components: Array<TextDisplay | Separator | Row<Button>> = [
new TextDisplay(`## ${params.title}`),
];
if (params.description) {
components.push(new TextDisplay(params.description));
}
components.push(new Separator({ divider: true, spacing: "small" }));
components.push(new TextDisplay(`### Command\n\`\`\`\n${params.commandPreview}\n\`\`\``));
if (params.commandSecondaryPreview) {
components.push(
new TextDisplay(`### Shell Preview\n\`\`\`\n${params.commandSecondaryPreview}\n\`\`\``),
);
}
if (params.metadataLines?.length) {
components.push(new TextDisplay(params.metadataLines.join("\n")));
}
if (params.actionRow) {
components.push(params.actionRow);
}
if (params.footer) {
components.push(new Separator({ divider: false, spacing: "small" }));
components.push(new TextDisplay(`-# ${params.footer}`));
}
super({
cfg: params.cfg,
accountId: params.accountId,
components,
accentColor: params.accentColor,
});
}
}
class ExecApprovalActionButton extends Button {
customId: string;
label: string;
style: ButtonStyle;
constructor(params: { approvalId: string; descriptor: ExecApprovalActionDescriptor }) {
super();
this.customId = buildExecApprovalCustomId(params.approvalId, params.descriptor.decision);
this.label = params.descriptor.label;
this.style =
params.descriptor.style === "success"
? ButtonStyle.Success
: params.descriptor.style === "primary"
? ButtonStyle.Primary
: params.descriptor.style === "danger"
? ButtonStyle.Danger
: ButtonStyle.Secondary;
}
}
class ExecApprovalActionRow extends Row<Button> {
constructor(params: { approvalId: string; actions: readonly ExecApprovalActionDescriptor[] }) {
super(
params.actions.map(
(descriptor) => new ExecApprovalActionButton({ approvalId: params.approvalId, descriptor }),
),
);
}
}
function createApprovalActionRow(view: PendingApprovalView): Row<Button> {
return new ExecApprovalActionRow({
approvalId: view.approvalId,
actions: view.actions,
});
}
function buildApprovalMetadataLines(
metadata: readonly { label: string; value: string }[],
): string[] {
return metadata.map((item) => `- ${item.label}: ${item.value}`);
}
function buildExecApprovalPayload(container: DiscordUiContainer): MessagePayloadObject {
const components: TopLevelComponents[] = [container];
return { components };
}
function formatCommandPreview(commandText: string, maxChars: number): string {
const commandRaw =
commandText.length > maxChars ? `${commandText.slice(0, maxChars)}...` : commandText;
return commandRaw.replace(/`/g, "\u200b`");
}
function formatOptionalCommandPreview(
commandText: string | null | undefined,
maxChars: number,
): string | null {
if (!commandText) {
return null;
}
return formatCommandPreview(commandText, maxChars);
}
function resolveCommandPreviews(
commandText: string,
commandPreview: string | null | undefined,
maxChars: number,
secondaryMaxChars: number,
): { commandPreview: string; commandSecondaryPreview: string | null } {
return {
commandPreview: formatCommandPreview(commandText, maxChars),
commandSecondaryPreview: formatOptionalCommandPreview(commandPreview, secondaryMaxChars),
};
}
function createExecApprovalRequestContainer(params: {
view: ExecApprovalPendingView;
cfg: OpenClawConfig;
accountId: string;
actionRow?: Row<Button>;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveCommandPreviews(
params.view.commandText,
params.view.commandPreview,
1000,
500,
);
const expiresAtSeconds = Math.max(0, Math.floor(params.view.expiresAtMs / 1000));
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Exec Approval Required",
description: "A command needs your approval.",
commandPreview,
commandSecondaryPreview,
metadataLines: buildApprovalMetadataLines(params.view.metadata),
actionRow: params.actionRow,
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.view.approvalId}`,
accentColor: "#FFA500",
});
}
function createPluginApprovalRequestContainer(params: {
view: PluginApprovalPendingView;
cfg: OpenClawConfig;
accountId: string;
actionRow?: Row<Button>;
}): ExecApprovalContainer {
const expiresAtSeconds = Math.max(0, Math.floor(params.view.expiresAtMs / 1000));
const severity = params.view.severity;
const accentColor =
severity === "critical" ? "#ED4245" : severity === "info" ? "#5865F2" : "#FAA61A";
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Plugin Approval Required",
description: "A plugin action needs your approval.",
commandPreview: formatCommandPreview(params.view.title, 700),
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
metadataLines: buildApprovalMetadataLines(params.view.metadata),
actionRow: params.actionRow,
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.view.approvalId}`,
accentColor,
});
}
function createExecResolvedContainer(params: {
view: ExecApprovalResolvedView;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveCommandPreviews(
params.view.commandText,
params.view.commandPreview,
500,
300,
);
const decisionLabel =
params.view.decision === "allow-once"
? "Allowed (once)"
: params.view.decision === "allow-always"
? "Allowed (always)"
: "Denied";
const accentColor =
params.view.decision === "deny"
? "#ED4245"
: params.view.decision === "allow-always"
? "#5865F2"
: "#57F287";
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: `Exec Approval: ${decisionLabel}`,
description: params.view.resolvedBy ? `Resolved by ${params.view.resolvedBy}` : "Resolved",
commandPreview,
commandSecondaryPreview,
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${params.view.approvalId}`,
accentColor,
});
}
function createPluginResolvedContainer(params: {
view: PluginApprovalResolvedView;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const decisionLabel =
params.view.decision === "allow-once"
? "Allowed (once)"
: params.view.decision === "allow-always"
? "Allowed (always)"
: "Denied";
const accentColor =
params.view.decision === "deny"
? "#ED4245"
: params.view.decision === "allow-always"
? "#5865F2"
: "#57F287";
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: `Plugin Approval: ${decisionLabel}`,
description: params.view.resolvedBy ? `Resolved by ${params.view.resolvedBy}` : "Resolved",
commandPreview: formatCommandPreview(params.view.title, 700),
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${params.view.approvalId}`,
accentColor,
});
}
function createExecExpiredContainer(params: {
view: ExecApprovalExpiredView;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveCommandPreviews(
params.view.commandText,
params.view.commandPreview,
500,
300,
);
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Exec Approval: Expired",
description: "This approval request has expired.",
commandPreview,
commandSecondaryPreview,
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${params.view.approvalId}`,
accentColor: "#99AAB5",
});
}
function createPluginExpiredContainer(params: {
view: PluginApprovalExpiredView;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Plugin Approval: Expired",
description: "This approval request has expired.",
commandPreview: formatCommandPreview(params.view.title, 700),
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${params.view.approvalId}`,
accentColor: "#99AAB5",
});
}
export function buildExecApprovalCustomId(
approvalId: string,
action: ExecApprovalDecision,
): string {
return [`execapproval:id=${encodeURIComponent(approvalId)}`, `action=${action}`].join(";");
}
async function updateMessage(params: {
cfg: OpenClawConfig;
accountId: string;
token: string;
channelId: string;
messageId: string;
container: DiscordUiContainer;
}): Promise<void> {
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: params.token, accountId: params.accountId },
params.cfg,
);
const payload = buildExecApprovalPayload(params.container);
await discordRequest(
() =>
rest.patch(Routes.channelMessage(params.channelId, params.messageId), {
body: stripUndefinedFields(serializePayload(payload)),
}),
"update-approval",
);
} catch (err) {
logError(`discord approvals: failed to update message: ${String(err)}`);
}
}
async function finalizeMessage(params: {
cfg: OpenClawConfig;
accountId: string;
token: string;
cleanupAfterResolve?: boolean;
channelId: string;
messageId: string;
container: DiscordUiContainer;
}): Promise<void> {
if (!params.cleanupAfterResolve) {
await updateMessage(params);
return;
}
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: params.token, accountId: params.accountId },
params.cfg,
);
await discordRequest(
() => rest.delete(Routes.channelMessage(params.channelId, params.messageId)) as Promise<void>,
"delete-approval",
);
} catch (err) {
logError(`discord approvals: failed to delete message: ${String(err)}`);
await updateMessage(params);
}
}
export const discordApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
DiscordPendingDelivery,
PreparedDeliveryTarget,
PendingApproval,
never
>({
eventKinds: ["exec", "plugin"],
resolveApprovalKind: (request) => (request.id.startsWith("plugin:") ? "plugin" : "exec"),
availability: {
isConfigured: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? isDiscordExecApprovalClientEnabled({
cfg: params.cfg,
accountId: resolved.accountId,
configOverride: resolved.context.config,
})
: false;
},
shouldHandle: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? shouldHandleDiscordApprovalRequest({
cfg: params.cfg,
accountId: resolved.accountId,
request: params.request,
configOverride: resolved.context.config,
})
: false;
},
},
presentation: {
buildPendingPayload: ({ cfg, accountId, context, view }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return { body: {} };
}
const actionRow = createApprovalActionRow(view);
const container =
view.approvalKind === "plugin"
? createPluginApprovalRequestContainer({
view: view,
cfg,
accountId: resolved.accountId,
actionRow,
})
: createExecApprovalRequestContainer({
view: view,
cfg,
accountId: resolved.accountId,
actionRow,
});
return {
body: stripUndefinedFields(serializePayload(buildExecApprovalPayload(container))),
};
},
buildResolvedResult: ({ cfg, accountId, context, view }) => {
const resolvedContext = resolveHandlerContext({ cfg, accountId, context });
if (!resolvedContext) {
return { kind: "delete" } as const;
}
const container =
view.approvalKind === "plugin"
? createPluginResolvedContainer({
view: view,
cfg,
accountId: resolvedContext.accountId,
})
: createExecResolvedContainer({
view: view,
cfg,
accountId: resolvedContext.accountId,
});
return { kind: "update", payload: container } as const;
},
buildExpiredResult: ({ cfg, accountId, context, view }) => {
const resolvedContext = resolveHandlerContext({ cfg, accountId, context });
if (!resolvedContext) {
return { kind: "delete" } as const;
}
const container =
view.approvalKind === "plugin"
? createPluginExpiredContainer({
view: view,
cfg,
accountId: resolvedContext.accountId,
})
: createExecExpiredContainer({
view: view,
cfg,
accountId: resolvedContext.accountId,
});
return { kind: "update", payload: container } as const;
},
},
transport: {
prepareTarget: async ({ cfg, accountId, context, plannedTarget }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
if (plannedTarget.surface === "origin") {
const destinationId =
typeof plannedTarget.target.threadId === "string" &&
plannedTarget.target.threadId.trim().length > 0
? plannedTarget.target.threadId.trim()
: plannedTarget.target.to;
return {
dedupeKey: destinationId,
target: {
discordChannelId: destinationId,
},
};
}
const { rest, request: discordRequest } = createDiscordClient(
{ token: resolved.context.token, accountId: resolved.accountId },
cfg,
);
const userId = plannedTarget.target.to;
const dmChannel = (await discordRequest(
() =>
rest.post(Routes.userChannels(), {
body: { recipient_id: userId },
}) as Promise<{ id: string }>,
"dm-channel",
)) as { id: string };
if (!dmChannel?.id) {
logError(`discord approvals: failed to create DM for user ${userId}`);
return null;
}
return {
dedupeKey: dmChannel.id,
target: {
discordChannelId: dmChannel.id,
recipientUserId: userId,
},
};
},
deliverPending: async ({
cfg,
accountId,
context,
plannedTarget,
preparedTarget,
pendingPayload,
}) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
const { rest, request: discordRequest } = createDiscordClient(
{ token: resolved.context.token, accountId: resolved.accountId },
cfg,
);
const message = (await discordRequest(
() =>
rest.post(Routes.channelMessages(preparedTarget.discordChannelId), {
body: pendingPayload.body,
}) as Promise<{ id: string; channel_id: string }>,
plannedTarget.surface === "origin" ? "send-approval-channel" : "send-approval",
)) as { id: string; channel_id: string };
if (!message?.id) {
if (plannedTarget.surface === "origin") {
logError("discord approvals: failed to send to channel");
} else if (preparedTarget.recipientUserId) {
logError(
`discord approvals: failed to send message to user ${preparedTarget.recipientUserId}`,
);
}
return null;
}
return {
discordMessageId: message.id,
discordChannelId: preparedTarget.discordChannelId,
};
},
updateEntry: async ({ cfg, accountId, context, entry, payload, phase }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const container = payload as DiscordUiContainer;
await finalizeMessage({
cfg,
accountId: resolved.accountId,
token: resolved.context.token,
cleanupAfterResolve:
phase === "resolved" ? resolved.context.config.cleanupAfterResolve : false,
channelId: entry.discordChannelId,
messageId: entry.discordMessageId,
container,
});
},
},
observe: {
onDuplicateSkipped: ({ preparedTarget, request }) => {
logDebug(
`discord approvals: skipping duplicate approval ${request.id} for channel ${preparedTarget.dedupeKey}`,
);
},
onDelivered: ({ plannedTarget, preparedTarget, request }) => {
if (plannedTarget.surface === "origin") {
logDebug(
`discord approvals: sent approval ${request.id} to channel ${preparedTarget.target.discordChannelId}`,
);
return;
}
logDebug(`discord approvals: sent approval ${request.id} to user ${plannedTarget.target.to}`);
},
onDeliveryError: ({ error, plannedTarget }) => {
if (plannedTarget.surface === "origin") {
logError(`discord approvals: failed to send to channel: ${String(error)}`);
return;
}
logError(
`discord approvals: failed to notify user ${plannedTarget.target.to}: ${String(error)}`,
);
},
},
});