diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index 8e9bba7d8..4ca25c8e2 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -1,7 +1,61 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { MoltbotConfig } from "../config/config.js"; -import { createExecApprovalForwarder } from "./exec-approval-forwarder.js"; +import { + buildTelegramExecApprovalCallbackData, + createExecApprovalForwarder, + parseTelegramExecApprovalCallbackData, +} from "./exec-approval-forwarder.js"; + +describe("telegram exec approval callback data", () => { + it("builds callback data with correct format", () => { + const data = buildTelegramExecApprovalCallbackData("uuid-123", "allow-once"); + expect(data).toBe("execapproval:uuid-123:allow-once"); + }); + + it("builds callback data for all decision types", () => { + expect(buildTelegramExecApprovalCallbackData("id", "allow-once")).toBe( + "execapproval:id:allow-once", + ); + expect(buildTelegramExecApprovalCallbackData("id", "allow-always")).toBe( + "execapproval:id:allow-always", + ); + expect(buildTelegramExecApprovalCallbackData("id", "deny")).toBe("execapproval:id:deny"); + }); + + it("parses valid callback data", () => { + const result = parseTelegramExecApprovalCallbackData("execapproval:uuid-123:allow-once"); + expect(result).toEqual({ approvalId: "uuid-123", action: "allow-once" }); + }); + + it("parses all decision types", () => { + expect(parseTelegramExecApprovalCallbackData("execapproval:id:allow-once")).toEqual({ + approvalId: "id", + action: "allow-once", + }); + expect(parseTelegramExecApprovalCallbackData("execapproval:id:allow-always")).toEqual({ + approvalId: "id", + action: "allow-always", + }); + expect(parseTelegramExecApprovalCallbackData("execapproval:id:deny")).toEqual({ + approvalId: "id", + action: "deny", + }); + }); + + it("returns null for invalid format", () => { + expect(parseTelegramExecApprovalCallbackData("invalid")).toBeNull(); + expect(parseTelegramExecApprovalCallbackData("other:id:deny")).toBeNull(); + expect(parseTelegramExecApprovalCallbackData("execapproval:id")).toBeNull(); + expect(parseTelegramExecApprovalCallbackData("execapproval:id:invalid-action")).toBeNull(); + }); + + it("roundtrips callback data correctly", () => { + const original = buildTelegramExecApprovalCallbackData("my-approval-id", "allow-always"); + const parsed = parseTelegramExecApprovalCallbackData(original); + expect(parsed).toEqual({ approvalId: "my-approval-id", action: "allow-always" }); + }); +}); const baseRequest = { id: "req-1", diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 776fbb1ec..e925db7ed 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -14,6 +14,49 @@ import { resolveSessionDeliveryTarget } from "./outbound/targets.js"; const log = createSubsystemLogger("gateway/exec-approvals"); +const EXEC_APPROVAL_KEY = "execapproval"; + +export function buildTelegramExecApprovalCallbackData( + approvalId: string, + action: ExecApprovalDecision, +): string { + return `${EXEC_APPROVAL_KEY}:${approvalId}:${action}`; +} + +export function parseTelegramExecApprovalCallbackData( + data: string, +): { approvalId: string; action: ExecApprovalDecision } | null { + const parts = data.split(":"); + if (parts.length < 3) return null; + if (parts[0] !== EXEC_APPROVAL_KEY) return null; + const action = parts[2] as ExecApprovalDecision; + if (action !== "allow-once" && action !== "allow-always" && action !== "deny") { + return null; + } + return { approvalId: parts[1], action }; +} + +function buildTelegramApprovalButtons( + approvalId: string, +): Array> { + return [ + [ + { + text: "βœ… Allow once", + callback_data: buildTelegramExecApprovalCallbackData(approvalId, "allow-once"), + }, + { + text: "♾️ Always allow", + callback_data: buildTelegramExecApprovalCallbackData(approvalId, "allow-always"), + }, + { + text: "❌ Deny", + callback_data: buildTelegramExecApprovalCallbackData(approvalId, "deny"), + }, + ], + ]; +} + export type ExecApprovalRequest = { id: string; request: { @@ -105,7 +148,12 @@ function buildTargetKey(target: ExecApprovalForwardTarget): string { return [channel, target.to, accountId, threadId].join(":"); } -function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) { +type RequestMessage = { + text: string; + buttons: Array>; +}; + +function buildRequestMessage(request: ExecApprovalRequest, nowMs: number): RequestMessage { const lines: string[] = ["πŸ”’ Exec approval required", `ID: ${request.id}`]; lines.push(`Command: ${request.request.command}`); if (request.request.cwd) lines.push(`CWD: ${request.request.cwd}`); @@ -115,8 +163,10 @@ function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) { if (request.request.ask) lines.push(`Ask: ${request.request.ask}`); const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMs) / 1000)); lines.push(`Expires in: ${expiresIn}s`); - lines.push("Reply with: /approve allow-once|allow-always|deny"); - return lines.join("\n"); + return { + text: lines.join("\n"), + buttons: buildTelegramApprovalButtons(request.id), + }; } function decisionLabel(decision: ExecApprovalDecision): string { @@ -162,6 +212,7 @@ async function deliverToTargets(params: { cfg: MoltbotConfig; targets: ForwardTarget[]; text: string; + buttons?: Array>; deliver: typeof deliverOutboundPayloads; shouldSend?: () => boolean; }) { @@ -170,13 +221,16 @@ async function deliverToTargets(params: { const channel = normalizeMessageChannel(target.channel) ?? target.channel; if (!isDeliverableMessageChannel(channel)) return; try { + const isTelegram = channel === "telegram"; + const channelData = + isTelegram && params.buttons ? { telegram: { buttons: params.buttons } } : undefined; await params.deliver({ cfg: params.cfg, channel, to: target.to, accountId: target.accountId, threadId: target.threadId, - payloads: [{ text: params.text }], + payloads: [{ text: params.text, channelData }], }); } catch (err) { log.error(`exec approvals: failed to deliver to ${channel}:${target.to}: ${String(err)}`); @@ -243,11 +297,12 @@ export function createExecApprovalForwarder( if (pending.get(request.id) !== pendingEntry) return; - const text = buildRequestMessage(request, nowMs()); + const message = buildRequestMessage(request, nowMs()); await deliverToTargets({ cfg, targets, - text, + text: message.text, + buttons: message.buttons, deliver, shouldSend: () => pending.get(request.id) === pendingEntry, }); diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 477b98280..0fa1c4d9c 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -369,6 +369,71 @@ export const registerTelegramHandlers = ({ return; } + const execApprovalParsed = await (async () => { + const { parseTelegramExecApprovalCallbackData } = + await import("../infra/exec-approval-forwarder.js"); + return parseTelegramExecApprovalCallbackData(data); + })().catch(() => null); + + if (execApprovalParsed) { + const { callGateway } = await import("../gateway/call.js"); + const { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } = + await import("../utils/message-channel.js"); + + const senderId = callback.from?.id ? String(callback.from.id) : "unknown"; + const senderUsername = callback.from?.username ?? ""; + const resolvedBy = `telegram:${senderUsername || senderId}`; + + const decisionLabel = + execApprovalParsed.action === "allow-once" + ? "Allowed (once)" + : execApprovalParsed.action === "allow-always" + ? "Allowed (always)" + : "Denied"; + + try { + await callGateway({ + method: "exec.approval.resolve", + params: { id: execApprovalParsed.approvalId, decision: execApprovalParsed.action }, + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + clientDisplayName: `Telegram approval (${resolvedBy})`, + mode: GATEWAY_CLIENT_MODES.BACKEND, + }); + + // Update the original message to show the decision and remove buttons + const decisionEmoji = execApprovalParsed.action === "deny" ? "❌" : "βœ…"; + const updatedText = `${decisionEmoji} Exec approval: ${decisionLabel}\nResolved by ${resolvedBy}\nID: ${execApprovalParsed.approvalId}`; + + try { + await bot.api.editMessageText( + callbackMessage.chat.id, + callbackMessage.message_id, + updatedText, + { reply_markup: { inline_keyboard: [] } }, + ); + } catch (editErr) { + const errStr = String(editErr); + if (!errStr.includes("message is not modified")) { + runtime.error?.(danger(`exec approval: failed to update message: ${errStr}`)); + } + } + } catch (err) { + runtime.error?.(danger(`exec approval: failed to resolve: ${String(err)}`)); + // Update message with error + try { + await bot.api.editMessageText( + callbackMessage.chat.id, + callbackMessage.message_id, + `❌ Failed to submit approval: ${String(err)}\nID: ${execApprovalParsed.approvalId}`, + { reply_markup: { inline_keyboard: [] } }, + ); + } catch { + // Ignore edit errors on failure path + } + } + return; + } + const syntheticMessage: TelegramMessage = { ...callbackMessage, from: callback.from,