Files
openclaw/extensions/discord/src/approval-handler.runtime.ts
Peter Steinberger 7a456e362d feat(channels): add typed cross-surface approval actions (#103679)
* fix(gateway): approval registry hardening and protocol-surface follow-ups

Follow-up delta to the merged #103579 head, rebased onto current main:
- gateway-protocol wire types derive from owner-module schema consts
  (types.ts tombstone) and ProtocolSchemas leaves the package index so the
  public plugin-sdk d.ts graph tree-shakes the registry declaration
- approval access authority follows the operator.approvals scope tier with
  reviewerDeviceIds as the opt-in restriction (cross-surface
  first-answer-wins; requester identity gates only legacy adapters)
- plugin node.invoke approvals register directly so unrenderable
  presentations fail closed before request routing
- exec-approval manager reconciliation with #103515 revocation hardening
  (resolution source attribution, one-shot ask-fallback consumption)
- surface-report pins and plugin-sdk API baseline refreshed; Swift models
  regenerated

* feat(channels): add typed operator approval actions

Squash-rebased #103679 segment onto the durable-approval-registry tip on
current main. Typed approval/command/select presentation actions replace
raw-string inference across slack/telegram/discord/matrix/imessage/whatsapp,
approval.resolve carries an explicit kind, and channel adapters map native
callback envelopes through the typed action registry.

Drift reconciliation: deprecated buildExecApprovalInteractiveReply assertions
dropped (#104650 removed the shims); worker_environments bootstrap-column
migration kept alongside the approval resolution_ref backfill; plugin-sdk API
baseline regenerated.

(cherry picked from commit 68765a5d39d2118c88a7a54d00387337912d4494)
(cherry picked from commit 8642ac12af142e4b751f4f30d4b114615e7e5f66)
(cherry picked from commit 036c4bc39499925fc03de16ec9302e346769350a)
(cherry picked from commit 19dc350d6bc34e29a5169c6bc80971b0ad12adde)
(cherry picked from commit fc978b0bad86aef421c79f6a211b25cc1b743c01)
(cherry picked from commit 10de4d1ed5071f9be6ad1ee5d1e32c0fa8c9d11c)
(cherry picked from commit 9a664ced1b1fa740172b258f355f1a82925ae41c)
(cherry picked from commit c5ff69abbf444139e9e007bfa45beb0f00ffea54)
(cherry picked from commit d466a80795)
(cherry picked from commit f5b4fe40dd5c961322f8553cc80b2fdfb3f6503e)
(cherry picked from commit 7340b4749a4cc4c72f7a41cce1bc9cb550cae038)
(cherry picked from commit a151f41808f23ae60b10305ccd2bc959b9169a86)

* fix(approvals): preserve typed transport ownership

* test(imessage): narrow chunked approval text

* refactor(protocol): remove retired type tombstone

* fix(plugin-sdk): align surface budgets after rebase

* docs(changelog): note typed operator approvals

* docs(changelog): defer typed approval release note
2026-07-11 18:31:05 -07:00

698 lines
22 KiB
TypeScript

// Discord plugin module implements approval handler behavior.
import { ButtonStyle } 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 { ExecApprovalActionDescriptor } from "openclaw/plugin-sdk/approval-reply-runtime";
import type {
DiscordExecApprovalConfig,
OpenClawConfig,
} from "openclaw/plugin-sdk/config-contracts";
import { logDebug, logError } from "openclaw/plugin-sdk/logging-core";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { buildExecApprovalCustomId } from "./approval-custom-id.js";
import {
DISCORD_APPROVAL_ALLOWED_MENTIONS,
formatDiscordApprovalDisplayValue,
} from "./approval-message-safety.js";
import { shouldHandleDiscordApprovalRequest } from "./approval-shared.js";
import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js";
import {
Button,
createChannelMessage,
createUserDmChannel,
deleteChannelMessage,
editChannelMessage,
Row,
Separator,
TextDisplay,
serializePayload,
type MessagePayloadObject,
type TopLevelComponents,
} from "./internal/discord.js";
import {
createDiscordClient,
createDiscordMessageNonce,
stripUndefinedFields,
} from "./send.shared.js";
import { DiscordUiContainer } from "./ui.js";
export { buildExecApprovalCustomId };
type PendingApproval = {
discordMessageId: string;
discordChannelId: string;
};
type DiscordPendingDelivery = {
body: ReturnType<typeof stripUndefinedFields>;
};
type PreparedDeliveryTarget = {
discordChannelId: string;
recipientUserId?: string;
};
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 {
override customId: string;
override label: string;
override style: ButtonStyle;
constructor(params: {
approvalId: string;
approvalKind: PendingApprovalView["approvalKind"];
descriptor: ExecApprovalActionDescriptor;
}) {
super();
this.customId = buildExecApprovalCustomId(
params.approvalId,
params.approvalKind,
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;
approvalKind: PendingApprovalView["approvalKind"];
actions: readonly ExecApprovalActionDescriptor[];
}) {
super(
params.actions.map(
(descriptor) =>
new ExecApprovalActionButton({
approvalId: params.approvalId,
approvalKind: params.approvalKind,
descriptor,
}),
),
);
}
}
function createApprovalActionRow(view: PendingApprovalView): Row<Button> {
return new ExecApprovalActionRow({
approvalId: view.approvalId,
approvalKind: view.approvalKind,
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, allowed_mentions: DISCORD_APPROVAL_ALLOWED_MENTIONS };
}
const commandPreviewSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
function* iterateCommandPreviewSegments(commandText: string): Iterable<string> {
if (!commandPreviewSegmenter) {
yield* Array.from(commandText);
return;
}
try {
for (const segment of commandPreviewSegmenter.segment(commandText)) {
yield segment.segment;
}
} catch {
yield* Array.from(commandText);
}
}
function truncateCommandPreview(commandText: string, maxChars: number): string {
let commandRaw = "";
for (const segment of iterateCommandPreviewSegments(commandText)) {
if (commandRaw.length + segment.length > maxChars) {
return `${commandRaw}...`;
}
commandRaw += segment;
}
return commandText;
}
function formatCommandPreview(commandText: string, maxChars: number): string {
return truncateCommandPreview(commandText, maxChars).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: ${formatDiscordApprovalDisplayValue(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: ${formatDiscordApprovalDisplayValue(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 ${formatDiscordApprovalDisplayValue(params.view.resolvedBy)}`
: "Resolved",
commandPreview,
commandSecondaryPreview,
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${formatDiscordApprovalDisplayValue(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 ${formatDiscordApprovalDisplayValue(params.view.resolvedBy)}`
: "Resolved",
commandPreview: formatCommandPreview(params.view.title, 700),
commandSecondaryPreview: formatOptionalCommandPreview(params.view.description, 1000),
metadataLines: buildApprovalMetadataLines(params.view.metadata),
footer: `ID: ${formatDiscordApprovalDisplayValue(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: ${formatDiscordApprovalDisplayValue(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: ${formatDiscordApprovalDisplayValue(params.view.approvalId)}`,
accentColor: "#99AAB5",
});
}
async function updateMessage(params: {
cfg: OpenClawConfig;
accountId: string;
token: string;
channelId: string;
messageId: string;
container: DiscordUiContainer;
}): Promise<void> {
try {
const { rest, request: discordRequest } = createDiscordClient({
cfg: params.cfg,
token: params.token,
accountId: params.accountId,
});
const payload = buildExecApprovalPayload(params.container);
await discordRequest(
() =>
editChannelMessage(rest, 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({
cfg: params.cfg,
token: params.token,
accountId: params.accountId,
});
await discordRequest(
() => deleteChannelMessage(rest, params.channelId, params.messageId),
"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"],
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,
cfg,
accountId: resolved.accountId,
actionRow,
})
: createExecApprovalRequestContainer({
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,
cfg,
accountId: resolvedContext.accountId,
})
: createExecResolvedContainer({
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,
cfg,
accountId: resolvedContext.accountId,
})
: createExecExpiredContainer({
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({
cfg,
token: resolved.context.token,
accountId: resolved.accountId,
});
const userId = plannedTarget.target.to;
const dmChannel = (await discordRequest(
() => createUserDmChannel(rest, userId),
"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({
cfg,
token: resolved.context.token,
accountId: resolved.accountId,
});
// Each destination is a distinct logical create. Reuse its nonce only across
// retries so multi-target approvals cannot deduplicate into the wrong channel.
const body = {
...pendingPayload.body,
nonce: createDiscordMessageNonce(),
enforce_nonce: true,
};
const message = (await discordRequest(
() =>
createChannelMessage<{ id: string; channel_id: string }>(
rest,
preparedTarget.discordChannelId,
{
body,
},
),
plannedTarget.surface === "origin" ? "send-approval-channel" : "send-approval",
{ safety: "nonce-protected-create" },
)) 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)}`,
);
},
},
});