diff --git a/CHANGELOG.md b/CHANGELOG.md index 64f296fd8..9ef37aef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.clawd.bot - TUI: reload history after gateway reconnect to restore session state. (#1663) - Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639) - Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338. +- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt. - Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev. - Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal - Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco. diff --git a/src/telegram/send.proxy.test.ts b/src/telegram/send.proxy.test.ts new file mode 100644 index 000000000..b395662e4 --- /dev/null +++ b/src/telegram/send.proxy.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { botApi, botCtorSpy } = vi.hoisted(() => ({ + botApi: { + sendMessage: vi.fn(), + setMessageReaction: vi.fn(), + deleteMessage: vi.fn(), + }, + botCtorSpy: vi.fn(), +})); + +const { loadConfig } = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({})), +})); + +const { makeProxyFetch } = vi.hoisted(() => ({ + makeProxyFetch: vi.fn(), +})); + +const { resolveTelegramFetch } = vi.hoisted(() => ({ + resolveTelegramFetch: vi.fn(), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig, + }; +}); + +vi.mock("./proxy.js", () => ({ + makeProxyFetch, +})); + +vi.mock("./fetch.js", () => ({ + resolveTelegramFetch, +})); + +vi.mock("grammy", () => ({ + Bot: class { + api = botApi; + constructor( + public token: string, + public options?: { client?: { fetch?: typeof fetch; timeoutSeconds?: number } }, + ) { + botCtorSpy(token, options); + } + }, + InputFile: class {}, +})); + +import { deleteMessageTelegram, reactMessageTelegram, sendMessageTelegram } from "./send.js"; + +describe("telegram proxy client", () => { + const proxyUrl = "http://proxy.test:8080"; + + beforeEach(() => { + botApi.sendMessage.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); + botApi.setMessageReaction.mockResolvedValue(undefined); + botApi.deleteMessage.mockResolvedValue(true); + botCtorSpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { telegram: { accounts: { foo: { proxy: proxyUrl } } } }, + }); + makeProxyFetch.mockReset(); + resolveTelegramFetch.mockReset(); + }); + + it("uses proxy fetch for sendMessage", async () => { + const proxyFetch = vi.fn(); + const fetchImpl = vi.fn(); + makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch); + resolveTelegramFetch.mockReturnValue(fetchImpl as unknown as typeof fetch); + + await sendMessageTelegram("123", "hi", { token: "tok", accountId: "foo" }); + + expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl); + expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch); + expect(botCtorSpy).toHaveBeenCalledWith( + "tok", + expect.objectContaining({ + client: expect.objectContaining({ fetch: fetchImpl }), + }), + ); + }); + + it("uses proxy fetch for reactions", async () => { + const proxyFetch = vi.fn(); + const fetchImpl = vi.fn(); + makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch); + resolveTelegramFetch.mockReturnValue(fetchImpl as unknown as typeof fetch); + + await reactMessageTelegram("123", "456", "✅", { token: "tok", accountId: "foo" }); + + expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl); + expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch); + expect(botCtorSpy).toHaveBeenCalledWith( + "tok", + expect.objectContaining({ + client: expect.objectContaining({ fetch: fetchImpl }), + }), + ); + }); + + it("uses proxy fetch for deleteMessage", async () => { + const proxyFetch = vi.fn(); + const fetchImpl = vi.fn(); + makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch); + resolveTelegramFetch.mockReturnValue(fetchImpl as unknown as typeof fetch); + + await deleteMessageTelegram("123", "456", { token: "tok", accountId: "foo" }); + + expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl); + expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch); + expect(botCtorSpy).toHaveBeenCalledWith( + "tok", + expect.objectContaining({ + client: expect.objectContaining({ fetch: fetchImpl }), + }), + ); + }); +}); diff --git a/src/telegram/send.ts b/src/telegram/send.ts index e38f9aad7..636676465 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -17,7 +17,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { mediaKindFromMime } from "../media/constants.js"; import { isGifMedia } from "../media/mime.js"; import { loadWebMedia } from "../web/media.js"; -import { resolveTelegramAccount } from "./accounts.js"; +import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; import { renderTelegramHtmlText } from "./format.js"; @@ -77,6 +77,25 @@ function createTelegramHttpLogger(cfg: ReturnType) { }; } +function resolveTelegramClientOptions( + account: ResolvedTelegramAccount, +): ApiClientOptions | undefined { + const proxyUrl = account.config.proxy?.trim(); + const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; + const fetchImpl = resolveTelegramFetch(proxyFetch); + const timeoutSeconds = + typeof account.config.timeoutSeconds === "number" && + Number.isFinite(account.config.timeoutSeconds) + ? Math.max(1, Math.floor(account.config.timeoutSeconds)) + : undefined; + return fetchImpl || timeoutSeconds + ? { + ...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}), + ...(timeoutSeconds ? { timeoutSeconds } : {}), + } + : undefined; +} + function resolveToken(explicit: string | undefined, params: { accountId: string; token: string }) { if (explicit?.trim()) return explicit.trim(); if (!params.token) { @@ -163,21 +182,7 @@ export async function sendMessageTelegram( const chatId = normalizeChatId(target.chatId); // Use provided api or create a new Bot instance. The nullish coalescing // operator ensures api is always defined (Bot.api is always non-null). - const proxyUrl = account.config.proxy; - const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl as string) : undefined; - const fetchImpl = resolveTelegramFetch(proxyFetch); - const timeoutSeconds = - typeof account.config.timeoutSeconds === "number" && - Number.isFinite(account.config.timeoutSeconds) - ? Math.max(1, Math.floor(account.config.timeoutSeconds)) - : undefined; - const client: ApiClientOptions | undefined = - fetchImpl || timeoutSeconds - ? { - ...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}), - ...(timeoutSeconds ? { timeoutSeconds } : {}), - } - : undefined; + const client = resolveTelegramClientOptions(account); const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; const mediaUrl = opts.mediaUrl?.trim(); const replyMarkup = buildInlineKeyboard(opts.buttons); @@ -419,12 +424,7 @@ export async function reactMessageTelegram( const token = resolveToken(opts.token, account); const chatId = normalizeChatId(String(chatIdInput)); const messageId = normalizeMessageId(messageIdInput); - const proxyUrl = account.config.proxy; - const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl as string) : undefined; - const fetchImpl = resolveTelegramFetch(proxyFetch); - const client: ApiClientOptions | undefined = fetchImpl - ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } - : undefined; + const client = resolveTelegramClientOptions(account); const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; const request = createTelegramRetryRunner({ retry: opts.retry, @@ -473,12 +473,7 @@ export async function deleteMessageTelegram( const token = resolveToken(opts.token, account); const chatId = normalizeChatId(String(chatIdInput)); const messageId = normalizeMessageId(messageIdInput); - const proxyUrl = account.config.proxy; - const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl as string) : undefined; - const fetchImpl = resolveTelegramFetch(proxyFetch); - const client: ApiClientOptions | undefined = fetchImpl - ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } - : undefined; + const client = resolveTelegramClientOptions(account); const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; const request = createTelegramRetryRunner({ retry: opts.retry,