Merge eb02a9e2d5 into 4583f88626
This commit is contained in:
commit
aee41fefef
@ -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",
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user