Merge eb02a9e2d5 into 4583f88626
This commit is contained in:
commit
aee41fefef
@ -1,7 +1,61 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { MoltbotConfig } from "../config/config.js";
|
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 = {
|
const baseRequest = {
|
||||||
id: "req-1",
|
id: "req-1",
|
||||||
|
|||||||
@ -14,6 +14,49 @@ import { resolveSessionDeliveryTarget } from "./outbound/targets.js";
|
|||||||
|
|
||||||
const log = createSubsystemLogger("gateway/exec-approvals");
|
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 = {
|
export type ExecApprovalRequest = {
|
||||||
id: string;
|
id: string;
|
||||||
request: {
|
request: {
|
||||||
@ -105,7 +148,12 @@ function buildTargetKey(target: ExecApprovalForwardTarget): string {
|
|||||||
return [channel, target.to, accountId, threadId].join(":");
|
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}`];
|
const lines: string[] = ["🔒 Exec approval required", `ID: ${request.id}`];
|
||||||
lines.push(`Command: ${request.request.command}`);
|
lines.push(`Command: ${request.request.command}`);
|
||||||
if (request.request.cwd) lines.push(`CWD: ${request.request.cwd}`);
|
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}`);
|
if (request.request.ask) lines.push(`Ask: ${request.request.ask}`);
|
||||||
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMs) / 1000));
|
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMs) / 1000));
|
||||||
lines.push(`Expires in: ${expiresIn}s`);
|
lines.push(`Expires in: ${expiresIn}s`);
|
||||||
lines.push("Reply with: /approve <id> allow-once|allow-always|deny");
|
return {
|
||||||
return lines.join("\n");
|
text: lines.join("\n"),
|
||||||
|
buttons: buildTelegramApprovalButtons(request.id),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function decisionLabel(decision: ExecApprovalDecision): string {
|
function decisionLabel(decision: ExecApprovalDecision): string {
|
||||||
@ -162,6 +212,7 @@ async function deliverToTargets(params: {
|
|||||||
cfg: MoltbotConfig;
|
cfg: MoltbotConfig;
|
||||||
targets: ForwardTarget[];
|
targets: ForwardTarget[];
|
||||||
text: string;
|
text: string;
|
||||||
|
buttons?: Array<Array<{ text: string; callback_data: string }>>;
|
||||||
deliver: typeof deliverOutboundPayloads;
|
deliver: typeof deliverOutboundPayloads;
|
||||||
shouldSend?: () => boolean;
|
shouldSend?: () => boolean;
|
||||||
}) {
|
}) {
|
||||||
@ -170,13 +221,16 @@ async function deliverToTargets(params: {
|
|||||||
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
|
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
|
||||||
if (!isDeliverableMessageChannel(channel)) return;
|
if (!isDeliverableMessageChannel(channel)) return;
|
||||||
try {
|
try {
|
||||||
|
const isTelegram = channel === "telegram";
|
||||||
|
const channelData =
|
||||||
|
isTelegram && params.buttons ? { telegram: { buttons: params.buttons } } : undefined;
|
||||||
await params.deliver({
|
await params.deliver({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
channel,
|
channel,
|
||||||
to: target.to,
|
to: target.to,
|
||||||
accountId: target.accountId,
|
accountId: target.accountId,
|
||||||
threadId: target.threadId,
|
threadId: target.threadId,
|
||||||
payloads: [{ text: params.text }],
|
payloads: [{ text: params.text, channelData }],
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error(`exec approvals: failed to deliver to ${channel}:${target.to}: ${String(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;
|
if (pending.get(request.id) !== pendingEntry) return;
|
||||||
|
|
||||||
const text = buildRequestMessage(request, nowMs());
|
const message = buildRequestMessage(request, nowMs());
|
||||||
await deliverToTargets({
|
await deliverToTargets({
|
||||||
cfg,
|
cfg,
|
||||||
targets,
|
targets,
|
||||||
text,
|
text: message.text,
|
||||||
|
buttons: message.buttons,
|
||||||
deliver,
|
deliver,
|
||||||
shouldSend: () => pending.get(request.id) === pendingEntry,
|
shouldSend: () => pending.get(request.id) === pendingEntry,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -369,6 +369,71 @@ export const registerTelegramHandlers = ({
|
|||||||
return;
|
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 = {
|
const syntheticMessage: TelegramMessage = {
|
||||||
...callbackMessage,
|
...callbackMessage,
|
||||||
from: callback.from,
|
from: callback.from,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user