This commit is contained in:
Roopesh S 2026-01-29 19:00:18 +00:00 committed by GitHub
commit aee41fefef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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,