openclaw/extensions/telegram/src/exec-approvals-handler.ts

340 lines
11 KiB
TypeScript

import { buildPluginApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
createExecApprovalChannelRuntime,
type ExecApprovalChannelRuntime,
resolveChannelNativeApprovalDeliveryPlan,
resolveExecApprovalSessionTarget,
} from "openclaw/plugin-sdk/infra-runtime";
import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime";
import {
buildExecApprovalPendingReplyPayload,
type ExecApprovalPendingReplyParams,
} from "openclaw/plugin-sdk/infra-runtime";
import type {
ExecApprovalRequest,
ExecApprovalResolved,
PluginApprovalRequest,
PluginApprovalResolved,
} from "openclaw/plugin-sdk/infra-runtime";
import { parseAgentSessionKey, normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { compileSafeRegex, testRegexWithBoundedInput } from "openclaw/plugin-sdk/security-runtime";
import { telegramNativeApprovalAdapter } from "./approval-native.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import {
getTelegramExecApprovalApprovers,
resolveTelegramExecApprovalConfig,
} from "./exec-approvals.js";
import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js";
const log = createSubsystemLogger("telegram/exec-approvals");
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved;
type ApprovalKind = "exec" | "plugin";
type PendingMessage = {
chatId: string;
messageId: string;
};
function isExecApprovalRequest(request: ApprovalRequest): request is ExecApprovalRequest {
return "command" in request.request;
}
function toExecLikeRequest(request: ApprovalRequest): ExecApprovalRequest {
if (isExecApprovalRequest(request)) {
return request;
}
return {
id: request.id,
request: {
command: request.request.title,
agentId: request.request.agentId ?? undefined,
sessionKey: request.request.sessionKey ?? undefined,
turnSourceChannel: request.request.turnSourceChannel ?? undefined,
turnSourceTo: request.request.turnSourceTo ?? undefined,
turnSourceAccountId: request.request.turnSourceAccountId ?? undefined,
turnSourceThreadId: request.request.turnSourceThreadId ?? undefined,
},
createdAtMs: request.createdAtMs,
expiresAtMs: request.expiresAtMs,
};
}
function resolveBoundTelegramAccountId(params: {
cfg: OpenClawConfig;
request: ApprovalRequest;
}): string | null {
const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase();
const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || undefined;
if (turnSourceChannel === "telegram") {
if (turnSourceAccountId) {
return turnSourceAccountId;
}
}
const allowSessionAccountFallback = turnSourceChannel === "telegram" && !turnSourceAccountId;
const sessionTarget = resolveExecApprovalSessionTarget({
cfg: params.cfg,
request: toExecLikeRequest(params.request),
turnSourceChannel: allowSessionAccountFallback
? undefined
: (params.request.request.turnSourceChannel ?? undefined),
turnSourceTo: allowSessionAccountFallback
? undefined
: (params.request.request.turnSourceTo ?? undefined),
turnSourceAccountId: allowSessionAccountFallback ? undefined : turnSourceAccountId,
turnSourceThreadId: allowSessionAccountFallback
? undefined
: (params.request.request.turnSourceThreadId ?? undefined),
});
if (!sessionTarget || sessionTarget.channel !== "telegram") {
return null;
}
return sessionTarget.accountId?.trim() || null;
}
export type TelegramExecApprovalHandlerOpts = {
token: string;
accountId: string;
cfg: OpenClawConfig;
gatewayUrl?: string;
runtime?: RuntimeEnv;
};
export type TelegramExecApprovalHandlerDeps = {
nowMs?: () => number;
sendTyping?: typeof sendTypingTelegram;
sendMessage?: typeof sendMessageTelegram;
editReplyMarkup?: typeof editMessageReplyMarkupTelegram;
};
function matchesFilters(params: {
cfg: OpenClawConfig;
accountId: string;
request: ApprovalRequest;
}): boolean {
const config = resolveTelegramExecApprovalConfig({
cfg: params.cfg,
accountId: params.accountId,
});
if (!config?.enabled) {
return false;
}
const approvers = getTelegramExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
});
if (approvers.length === 0) {
return false;
}
if (config.agentFilter?.length) {
const agentId =
params.request.request.agentId ??
parseAgentSessionKey(params.request.request.sessionKey)?.agentId;
if (!agentId || !config.agentFilter.includes(agentId)) {
return false;
}
}
if (config.sessionFilter?.length) {
const sessionKey = params.request.request.sessionKey;
if (!sessionKey) {
return false;
}
const matches = config.sessionFilter.some((pattern) => {
if (sessionKey.includes(pattern)) {
return true;
}
const regex = compileSafeRegex(pattern);
return regex ? testRegexWithBoundedInput(regex, sessionKey) : false;
});
if (!matches) {
return false;
}
}
const boundAccountId = resolveBoundTelegramAccountId({
cfg: params.cfg,
request: params.request,
});
if (
boundAccountId &&
normalizeAccountId(boundAccountId) !== normalizeAccountId(params.accountId)
) {
return false;
}
return true;
}
function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string }): boolean {
const config = resolveTelegramExecApprovalConfig({
cfg: params.cfg,
accountId: params.accountId,
});
if (!config?.enabled) {
return false;
}
return (
getTelegramExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
}).length > 0
);
}
export class TelegramExecApprovalHandler {
private readonly runtime: ExecApprovalChannelRuntime<ApprovalRequest, ApprovalResolved>;
private readonly nowMs: () => number;
private readonly sendTyping: typeof sendTypingTelegram;
private readonly sendMessage: typeof sendMessageTelegram;
private readonly editReplyMarkup: typeof editMessageReplyMarkupTelegram;
constructor(
private readonly opts: TelegramExecApprovalHandlerOpts,
deps: TelegramExecApprovalHandlerDeps = {},
) {
this.nowMs = deps.nowMs ?? Date.now;
this.sendTyping = deps.sendTyping ?? sendTypingTelegram;
this.sendMessage = deps.sendMessage ?? sendMessageTelegram;
this.editReplyMarkup = deps.editReplyMarkup ?? editMessageReplyMarkupTelegram;
this.runtime = createExecApprovalChannelRuntime<
PendingMessage,
ApprovalRequest,
ApprovalResolved
>({
label: "telegram/exec-approvals",
clientDisplayName: `Telegram Exec Approvals (${this.opts.accountId})`,
cfg: this.opts.cfg,
gatewayUrl: this.opts.gatewayUrl,
eventKinds: ["exec", "plugin"],
nowMs: this.nowMs,
isConfigured: () =>
isHandlerConfigured({ cfg: this.opts.cfg, accountId: this.opts.accountId }),
shouldHandle: (request) =>
matchesFilters({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
request,
}),
deliverRequested: async (request) => await this.deliverRequested(request),
finalizeResolved: async ({ resolved, entries }) => {
await this.finalizeResolved(resolved, entries);
},
finalizeExpired: async ({ entries }) => {
await this.clearPending(entries);
},
});
}
shouldHandle(request: ApprovalRequest): boolean {
return matchesFilters({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
request,
});
}
async start(): Promise<void> {
await this.runtime.start();
}
async stop(): Promise<void> {
await this.runtime.stop();
}
async handleRequested(request: ApprovalRequest): Promise<void> {
await this.runtime.handleRequested(request);
}
private async deliverRequested(request: ApprovalRequest): Promise<PendingMessage[]> {
const approvalKind: ApprovalKind = request.id.startsWith("plugin:") ? "plugin" : "exec";
const deliveryPlan = await resolveChannelNativeApprovalDeliveryPlan({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
approvalKind,
request,
adapter: telegramNativeApprovalAdapter.native,
});
if (deliveryPlan.targets.length === 0) {
return [];
}
const payload =
approvalKind === "plugin"
? buildPluginApprovalPendingReplyPayload({
request: request as PluginApprovalRequest,
nowMs: this.nowMs(),
})
: buildExecApprovalPendingReplyPayload({
approvalId: request.id,
approvalSlug: request.id.slice(0, 8),
approvalCommandId: request.id,
command: resolveExecApprovalCommandDisplay((request as ExecApprovalRequest).request)
.commandText,
cwd: (request as ExecApprovalRequest).request.cwd ?? undefined,
host: (request as ExecApprovalRequest).request.host === "node" ? "node" : "gateway",
nodeId: (request as ExecApprovalRequest).request.nodeId ?? undefined,
expiresAtMs: request.expiresAtMs,
nowMs: this.nowMs(),
} satisfies ExecApprovalPendingReplyParams);
const buttons = resolveTelegramInlineButtons({
interactive: payload.interactive,
});
const sentMessages: PendingMessage[] = [];
for (const target of deliveryPlan.targets) {
try {
await this.sendTyping(target.target.to, {
cfg: this.opts.cfg,
token: this.opts.token,
accountId: this.opts.accountId,
...(typeof target.target.threadId === "number"
? { messageThreadId: target.target.threadId }
: {}),
}).catch(() => {});
const result = await this.sendMessage(target.target.to, payload.text ?? "", {
cfg: this.opts.cfg,
token: this.opts.token,
accountId: this.opts.accountId,
buttons,
...(typeof target.target.threadId === "number"
? { messageThreadId: target.target.threadId }
: {}),
});
sentMessages.push({
chatId: result.chatId,
messageId: result.messageId,
});
} catch (err) {
log.error(`telegram exec approvals: failed to send request ${request.id}: ${String(err)}`);
}
}
return sentMessages;
}
async handleResolved(resolved: ApprovalResolved): Promise<void> {
await this.runtime.handleResolved(resolved);
}
private async finalizeResolved(
_resolved: ApprovalResolved,
messages: PendingMessage[],
): Promise<void> {
await this.clearPending(messages);
}
private async clearPending(messages: PendingMessage[]): Promise<void> {
await Promise.allSettled(
messages.map(async (message) => {
await this.editReplyMarkup(message.chatId, message.messageId, [], {
cfg: this.opts.cfg,
token: this.opts.token,
accountId: this.opts.accountId,
});
}),
);
}
}