Files
openclaw/extensions/telegram/src/approval-handler.runtime.ts
Pablo Guardiola 0c867eef75 feat: expose plugin approval action metadata
Expose plugin approval action metadata so plugins can describe richer approval actions across gateway, SDK, channel, and UI surfaces.
2026-05-26 22:46:09 -07:00

232 lines
7.9 KiB
TypeScript

import type {
ChannelApprovalCapabilityHandlerContext,
PendingApprovalView,
} 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 { buildPluginApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
import {
buildApprovalPresentationFromActionDescriptors,
buildExecApprovalPendingReplyPayload,
} from "openclaw/plugin-sdk/approval-reply-runtime";
import type {
ExecApprovalActionDescriptor,
ExecApprovalPendingReplyParams,
ExecApprovalReplyDecision,
} from "openclaw/plugin-sdk/approval-reply-runtime";
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveTelegramInlineButtons } from "./button-types.js";
import {
isTelegramExecApprovalHandlerConfigured,
shouldHandleTelegramExecApprovalRequest,
} from "./exec-approvals.js";
import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js";
const log = createSubsystemLogger("telegram/approvals");
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type PendingMessage = {
chatId: string;
messageId: string;
};
type TelegramPendingDelivery = {
text: string;
buttons: ReturnType<typeof resolveTelegramInlineButtons>;
};
export type TelegramExecApprovalHandlerDeps = {
nowMs?: () => number;
sendTyping?: typeof sendTypingTelegram;
sendMessage?: typeof sendMessageTelegram;
editReplyMarkup?: typeof editMessageReplyMarkupTelegram;
};
export type TelegramApprovalHandlerContext = {
token: string;
deps?: TelegramExecApprovalHandlerDeps;
};
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
accountId: string;
context: TelegramApprovalHandlerContext;
} | null {
const context = params.context as TelegramApprovalHandlerContext | undefined;
const accountId = normalizeOptionalString(params.accountId) ?? "";
if (!context?.token || !accountId) {
return null;
}
return { accountId, context };
}
function listDecisionActions(view: PendingApprovalView): ExecApprovalReplyDecision[] {
return view.actions.flatMap((action) => (action.kind === "decision" ? [action.decision] : []));
}
function buildTelegramApprovalCommandText(params: {
approvalCommandId: string;
decision: ExecApprovalReplyDecision;
}): string {
return `/approve ${params.approvalCommandId} ${params.decision}`;
}
function listNativeButtonActions(view: PendingApprovalView): ExecApprovalActionDescriptor[] {
return view.actions.flatMap((action) =>
action.kind === "decision"
? [
{
kind: "decision",
decision: action.decision,
label: action.label,
style: action.style,
command: buildTelegramApprovalCommandText({
approvalCommandId: view.approvalId,
decision: action.decision,
}),
},
]
: [],
);
}
function buildPendingPayload(params: {
request: ApprovalRequest;
approvalKind: "exec" | "plugin";
nowMs: number;
view: PendingApprovalView;
}): TelegramPendingDelivery {
const payload =
params.approvalKind === "plugin"
? buildPluginApprovalPendingReplyPayload({
request: params.request as PluginApprovalRequest,
nowMs: params.nowMs,
})
: buildExecApprovalPendingReplyPayload({
approvalId: params.request.id,
approvalSlug: params.request.id.slice(0, 8),
approvalCommandId: params.request.id,
warningText:
params.view.approvalKind === "exec"
? (params.view.warningText ?? undefined)
: undefined,
command: params.view.approvalKind === "exec" ? params.view.commandText : "",
cwd: params.view.approvalKind === "exec" ? (params.view.cwd ?? undefined) : undefined,
host:
params.view.approvalKind === "exec" && params.view.host === "node" ? "node" : "gateway",
nodeId:
params.view.approvalKind === "exec" ? (params.view.nodeId ?? undefined) : undefined,
allowedDecisions: listDecisionActions(params.view),
expiresAtMs: params.request.expiresAtMs,
nowMs: params.nowMs,
} satisfies ExecApprovalPendingReplyParams);
return {
text: payload.text ?? "",
buttons: resolveTelegramInlineButtons({
presentation: buildApprovalPresentationFromActionDescriptors(
listNativeButtonActions(params.view),
),
}),
};
}
export const telegramApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
TelegramPendingDelivery,
{ chatId: string; messageThreadId?: number },
PendingMessage,
never
>({
eventKinds: ["exec", "plugin"],
availability: {
isConfigured: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? isTelegramExecApprovalHandlerConfigured({
cfg: params.cfg,
accountId: resolved.accountId,
})
: false;
},
shouldHandle: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? shouldHandleTelegramExecApprovalRequest({
cfg: params.cfg,
accountId: resolved.accountId,
request: params.request,
})
: false;
},
},
presentation: {
buildPendingPayload: ({ request, approvalKind, nowMs, view }) =>
buildPendingPayload({ request, approvalKind, nowMs, view }),
buildResolvedResult: () => ({ kind: "clear-actions" }),
buildExpiredResult: () => ({ kind: "clear-actions" }),
},
transport: {
prepareTarget: ({ plannedTarget }) => ({
dedupeKey: buildChannelApprovalNativeTargetKey(plannedTarget.target),
target: {
chatId: plannedTarget.target.to,
messageThreadId:
typeof plannedTarget.target.threadId === "number"
? plannedTarget.target.threadId
: undefined,
},
}),
deliverPending: async ({ cfg, accountId, context, preparedTarget, pendingPayload }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
const sendTyping = resolved.context.deps?.sendTyping ?? sendTypingTelegram;
const sendMessage = resolved.context.deps?.sendMessage ?? sendMessageTelegram;
await sendTyping(preparedTarget.chatId, {
cfg,
token: resolved.context.token,
accountId: resolved.accountId,
...(preparedTarget.messageThreadId != null
? { messageThreadId: preparedTarget.messageThreadId }
: {}),
}).catch(() => {});
const result = await sendMessage(preparedTarget.chatId, pendingPayload.text, {
cfg,
token: resolved.context.token,
accountId: resolved.accountId,
buttons: pendingPayload.buttons,
...(preparedTarget.messageThreadId != null
? { messageThreadId: preparedTarget.messageThreadId }
: {}),
});
return {
chatId: result.chatId,
messageId: result.messageId,
};
},
},
interactions: {
clearPendingActions: async ({ cfg, accountId, context, entry }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const editReplyMarkup =
resolved.context.deps?.editReplyMarkup ?? editMessageReplyMarkupTelegram;
await editReplyMarkup(entry.chatId, entry.messageId, [], {
cfg,
token: resolved.context.token,
accountId: resolved.accountId,
});
},
},
observe: {
onDeliveryError: ({ error, request }) => {
log.error(`telegram approvals: failed to send request ${request.id}: ${String(error)}`);
},
},
});