import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { updateSessionStore } from "../../../src/config/sessions.js"; import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js"; const baseRequest = { id: "9f1c7d5d-b1fb-46ef-ac45-662723b65bb7", request: { command: "npm view diver name version description", agentId: "main", sessionKey: "agent:main:telegram:group:-1003841603622:topic:928", turnSourceChannel: "telegram", turnSourceTo: "-1003841603622", turnSourceThreadId: "928", turnSourceAccountId: "default", }, createdAtMs: 1000, expiresAtMs: 61_000, }; const pluginRequest = { id: "plugin:9f1c7d5d-b1fb-46ef-ac45-662723b65bb7", request: { title: "Plugin Approval Required", description: "Allow plugin access", pluginId: "git-tools", agentId: "main", sessionKey: "agent:main:telegram:group:-1003841603622:topic:928", turnSourceChannel: "telegram", turnSourceTo: "-1003841603622", turnSourceThreadId: "928", turnSourceAccountId: "default", }, createdAtMs: 1000, expiresAtMs: 61_000, }; function createHandler(cfg: OpenClawConfig, accountId = "default") { const sendTyping = vi.fn().mockResolvedValue({ ok: true }); const sendMessage = vi .fn() .mockResolvedValueOnce({ messageId: "m1", chatId: "-1003841603622" }) .mockResolvedValue({ messageId: "m2", chatId: "8460800771" }); const editReplyMarkup = vi.fn().mockResolvedValue({ ok: true }); const handler = new TelegramExecApprovalHandler( { token: "tg-token", accountId, cfg, }, { nowMs: () => 1000, sendTyping, sendMessage, editReplyMarkup, }, ); return { handler, sendTyping, sendMessage, editReplyMarkup }; } describe("TelegramExecApprovalHandler", () => { it("sends approval prompts to the originating telegram topic when target=channel", async () => { const cfg = { channels: { telegram: { execApprovals: { enabled: true, approvers: ["8460800771"], target: "channel", }, }, }, } as OpenClawConfig; const { handler, sendTyping, sendMessage } = createHandler(cfg); await handler.handleRequested(baseRequest); expect(sendTyping).toHaveBeenCalledWith( "-1003841603622", expect.objectContaining({ accountId: "default", messageThreadId: 928, }), ); expect(sendMessage).toHaveBeenCalledWith( "-1003841603622", expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"), expect.objectContaining({ accountId: "default", messageThreadId: 928, buttons: [ [ { text: "Allow Once", callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once", style: "success", }, { text: "Allow Always", callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 always", style: "primary", }, { text: "Deny", callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 deny", style: "danger", }, ], ], }), ); }); it("falls back to approver DMs when channel routing is unavailable", async () => { const cfg = { channels: { telegram: { execApprovals: { enabled: true, approvers: ["111", "222"], target: "channel", }, }, }, } as OpenClawConfig; const { handler, sendMessage } = createHandler(cfg); await handler.handleRequested({ ...baseRequest, request: { ...baseRequest.request, turnSourceChannel: "slack", turnSourceTo: "U1", turnSourceAccountId: null, turnSourceThreadId: null, }, }); expect(sendMessage).toHaveBeenCalledTimes(2); expect(sendMessage.mock.calls.map((call) => call[0])).toEqual(["111", "222"]); }); it("clears buttons from tracked approval messages when resolved", async () => { const cfg = { channels: { telegram: { execApprovals: { enabled: true, approvers: ["8460800771"], target: "both", }, }, }, } as OpenClawConfig; const { handler, editReplyMarkup } = createHandler(cfg); await handler.handleRequested(baseRequest); await handler.handleResolved({ id: baseRequest.id, decision: "allow-once", resolvedBy: "telegram:8460800771", ts: 2000, }); expect(editReplyMarkup).toHaveBeenCalled(); expect(editReplyMarkup).toHaveBeenCalledWith( "-1003841603622", "m1", [], expect.objectContaining({ accountId: "default", }), ); }); it("delivers plugin approvals through the shared native delivery planner", async () => { const cfg = { channels: { telegram: { execApprovals: { enabled: true, approvers: ["8460800771"], target: "dm", }, }, }, } as OpenClawConfig; const { handler, sendMessage } = createHandler(cfg); await handler.handleRequested(pluginRequest); const [chatId, text, options] = sendMessage.mock.calls[0] ?? []; expect(chatId).toBe("8460800771"); expect(text).toContain("Plugin approval required"); expect(options).toEqual( expect.objectContaining({ accountId: "default", buttons: expect.arrayContaining([ expect.arrayContaining([ expect.objectContaining({ callback_data: "/approve plugin:9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once", }), ]), ]), }), ); }); it("does not deliver plugin approvals for a different Telegram account", async () => { const cfg = { channels: { telegram: { execApprovals: { enabled: true, approvers: ["8460800771"], target: "dm", }, accounts: { secondary: { execApprovals: { enabled: true, approvers: ["999"], target: "dm", }, }, }, }, }, } as OpenClawConfig; const { handler, sendMessage } = createHandler(cfg); await handler.handleRequested({ ...pluginRequest, request: { ...pluginRequest.request, turnSourceAccountId: "secondary", }, }); expect(sendMessage).not.toHaveBeenCalled(); }); it("falls back to the session-bound Telegram account when turn source account is missing", async () => { const sessionStoreDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tg-approvals-")); const storePath = path.join(sessionStoreDir, "sessions.json"); try { await updateSessionStore(storePath, (store) => { store[baseRequest.request.sessionKey] = { sessionId: "session-secondary", updatedAt: Date.now(), deliveryContext: { channel: "telegram", to: "-1003841603622", accountId: "secondary", threadId: 928, }, }; }); const cfg = { session: { store: storePath }, channels: { telegram: { execApprovals: { enabled: true, approvers: ["8460800771"], target: "channel", }, accounts: { secondary: { execApprovals: { enabled: true, approvers: ["999"], target: "channel", }, }, }, }, }, } as OpenClawConfig; const defaultHandler = createHandler(cfg, "default"); const secondaryHandler = createHandler(cfg, "secondary"); const request = { ...baseRequest, request: { ...baseRequest.request, turnSourceAccountId: null, }, }; await defaultHandler.handler.handleRequested(request); await secondaryHandler.handler.handleRequested(request); expect(defaultHandler.sendMessage).not.toHaveBeenCalled(); expect(secondaryHandler.sendMessage).toHaveBeenCalledWith( "-1003841603622", expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"), expect.objectContaining({ accountId: "secondary", messageThreadId: 928, }), ); } finally { await fs.rm(sessionStoreDir, { recursive: true, force: true }); } }); });