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.
This commit is contained in:
Roopesh 2026-01-29 18:17:12 +05:30
parent 5f4715acfc
commit eb02a9e2d5
3 changed files with 181 additions and 7 deletions

View File

@ -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",

View File

@ -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<Array<{ text: string; callback_data: string }>> {
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<Array<{ text: string; callback_data: string }>>;
};
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 <id> 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<Array<{ text: string; callback_data: string }>>;
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,
});

View File

@ -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,