From eb02a9e2d5a7435287a4080d0e70608b45dee0ef Mon Sep 17 00:00:00 2001 From: Roopesh Date: Thu, 29 Jan 2026 18:17:12 +0530 Subject: [PATCH] Add inline buttons to Telegram exec approval messages When exec approval requests are forwarded to Telegram, the message now includes inline keyboard buttons for Allow once, Always allow, and Deny actions instead of requiring users to type the /approve command manually. The callback handler parses button clicks and resolves approvals via the gateway, then updates the message with the result. --- src/infra/exec-approval-forwarder.test.ts | 56 ++++++++++++++++++- src/infra/exec-approval-forwarder.ts | 67 +++++++++++++++++++++-- src/telegram/bot-handlers.ts | 65 ++++++++++++++++++++++ 3 files changed, 181 insertions(+), 7 deletions(-) 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,