From 5f5846a08b01bac832a93eb6c7b3673e2d333117 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Dec 2025 22:52:57 +0100 Subject: [PATCH] Telegram: enable grammY throttler and webhook tests --- CHANGELOG.md | 4 +-- README.md | 6 ++-- docs/research/grammy.md | 22 ++++++++++++++ docs/telegram.md | 2 +- src/telegram/bot.test.ts | 33 +++++++++++++++++++++ src/telegram/bot.ts | 2 ++ src/telegram/webhook.test.ts | 57 ++++++++++++++++++++++++++++++++++++ 7 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 docs/research/grammy.md create mode 100644 src/telegram/bot.test.ts create mode 100644 src/telegram/webhook.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fb34f003e..caf8e5cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,8 +34,8 @@ First Clawdis release after the Warelay rebrand. This is a semver-major because - Launchd PATH and helper lookup hardened for packaged macOS builds; health probes surface missing binaries quickly. ### Docs -- Added `docs/telegram.md` outlining the upcoming Telegram Bot API provider (grammY-based) and how it will share the `main` session. -- CLI now exposes `relay:telegram` and text/media sends via `--provider telegram`; typing/webhook still pending. +- Added `docs/telegram.md` outlining the Telegram Bot API provider (grammY) and how it shares the `main` session. Default grammY throttler keeps Bot API calls under rate limits. +- CLI exposes `relay:telegram` (grammY) and text/media sends via `--provider telegram`; webhook/proxy options documented. ## 1.5.0 — 2025-12-05 diff --git a/README.md b/README.md index a299c49ca..5e9e6f6f3 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Create `~/.clawdis/clawdis.json`: - [Security](./docs/security.md) - [Troubleshooting](./docs/troubleshooting.md) - [The Lore](./docs/lore.md) 🦞 -- [Telegram (Bot API) — WIP](./docs/telegram.md) +- [Telegram (Bot API)](./docs/telegram.md) ## Clawd @@ -120,8 +120,8 @@ clawdis login # Scan QR code clawdis relay # Start listening ``` -### Telegram (Bot API) — WIP -Bot-mode support (long-poll) shares the same `main` session as WhatsApp/WebChat, with groups kept isolated. Text and media send work via `clawdis send --provider telegram`; a relay is available via `clawdis relay:telegram` (TELEGRAM_BOT_TOKEN or telegram.botToken in config). See `docs/telegram.md` for current limits and setup. +### Telegram (Bot API) +Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebChat, with groups kept isolated. Text and media send work via `clawdis send --provider telegram`; a relay is available via `clawdis relay:telegram` (TELEGRAM_BOT_TOKEN or telegram.botToken in config). Webhook mode: `--webhook --port … --webhook-secret … --webhook-url …` (or register via BotFather). See `docs/telegram.md` for setup and limits. ## Commands diff --git a/docs/research/grammy.md b/docs/research/grammy.md new file mode 100644 index 000000000..c8e1cb768 --- /dev/null +++ b/docs/research/grammy.md @@ -0,0 +1,22 @@ +# grammY Integration (Telegram Bot API) + +Updated: 2025-12-07 + +# Why grammY +- TS-first Bot API client with built-in long-poll + webhook runners, middleware, error handling, rate limiter. +- Cleaner media helpers than hand-rolling fetch + FormData; supports all Bot API methods. +- Extensible: proxy support via custom fetch, session middleware (optional), type-safe context. + +# What we shipped +- **Single client path:** fetch-based implementation removed; grammY is now the sole Telegram client (send + relay) with the grammY throttler enabled by default. +- **Relay:** `monitorTelegramProvider` builds a grammY `Bot`, wires mention/allowlist gating, media download via `getFile`/`download`, and delivers replies with `sendMessage/sendPhoto/sendVideo/sendAudio/sendDocument`. Supports long-poll or webhook via `webhookCallback`. +- **Proxy:** optional `telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`. +- **Webhook helpers:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown and optional `--webhook-url` override. +- **Sessions:** direct chats map to `main`; groups map to `group:`; replies route back to the same surface. +- **Config knobs:** `telegram.botToken`, `requireMention`, `allowFrom`, `mediaMaxMb`, `proxy`, `webhookSecret`, `webhookUrl`. +- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome. + +Open questions +- Optional grammY plugins (throttler) if we hit Bot API 429s. +- Add more structured media tests (stickers, voice notes). +- Expose a `--public-url` flag in CLI for webhook registration convenience (currently `--webhook-url`). diff --git a/docs/telegram.md b/docs/telegram.md index 84843b405..7c5a977db 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -24,7 +24,7 @@ Status: ready for bot-mode use with grammY (long-poll + webhook). Text + media s - Typing indicators (`sendChatAction`) supported; inline reply/threading supported where Telegram allows. ## Planned implementation details -- Library: grammY is the only client for send + relay (fetch fallback removed). +- Library: grammY is the only client for send + relay (fetch fallback removed); grammY throttler is enabled by default to stay under Bot API limits. - Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, and `Timestamp`; groups require @bot mention by default. - Outbound: text and media (photo/video/audio/document) with optional caption; chunked to limits. Typing cue sent best-effort. - Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.requireMention`, `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl` supported. diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts new file mode 100644 index 000000000..9482b5465 --- /dev/null +++ b/src/telegram/bot.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest"; + +const useSpy = vi.fn(); +const onSpy = vi.fn(); +const stopSpy = vi.fn(); +const apiStub = { config: { use: useSpy } }; + +vi.mock("grammy", () => ({ + Bot: class { + api = apiStub as any; + on = onSpy; + stop = stopSpy; + constructor(public token: string) {} + }, + InputFile: class {}, + webhookCallback: vi.fn(), +})); + +const throttlerSpy = vi.fn(() => "throttler"); + +vi.mock("@grammyjs/transformer-throttler", () => ({ + apiThrottler: () => throttlerSpy(), +})); + +import { createTelegramBot } from "./bot.js"; + +describe("createTelegramBot", () => { + it("installs grammY throttler", () => { + createTelegramBot({ token: "tok" }); + expect(throttlerSpy).toHaveBeenCalledTimes(1); + expect(useSpy).toHaveBeenCalledWith("throttler"); + }); +}); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index ec88b85bf..7688a8487 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -1,6 +1,7 @@ import { Buffer } from "node:buffer"; import { Bot, InputFile, webhookCallback } from "grammy"; +import { apiThrottler } from "@grammyjs/transformer-throttler"; import type { ApiClientOptions } from "grammy"; import { chunkText } from "../auto-reply/chunk.js"; @@ -38,6 +39,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { : undefined; const bot = new Bot(opts.token, { client }); + bot.api.config.use(apiThrottler()); const cfg = loadConfig(); const requireMention = diff --git a/src/telegram/webhook.test.ts b/src/telegram/webhook.test.ts new file mode 100644 index 000000000..18832b242 --- /dev/null +++ b/src/telegram/webhook.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; + +import { startTelegramWebhook } from "./webhook.js"; + +const handlerSpy = vi.fn((req: any, res: any) => { + res.writeHead(200); + res.end("ok"); +}); +const setWebhookSpy = vi.fn(); +const stopSpy = vi.fn(); + +vi.mock("grammy", () => ({ + webhookCallback: () => handlerSpy, +})); + +vi.mock("./bot.js", () => ({ + createTelegramBot: () => ({ + api: { setWebhook: setWebhookSpy }, + stop: stopSpy, + }), +})); + +describe("startTelegramWebhook", () => { + it("starts server, registers webhook, and serves health", async () => { + const abort = new AbortController(); + const { server } = await startTelegramWebhook({ + token: "tok", + port: 0, // random free port + abortSignal: abort.signal, + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("no address"); + const url = `http://127.0.0.1:${address.port}`; + + const health = await fetch(`${url}/healthz`); + expect(health.status).toBe(200); + expect(setWebhookSpy).toHaveBeenCalled(); + + abort.abort(); + }); + + it("invokes webhook handler on matching path", async () => { + handlerSpy.mockClear(); + const abort = new AbortController(); + const { server } = await startTelegramWebhook({ + token: "tok", + port: 0, + abortSignal: abort.signal, + path: "/hook", + }); + const addr = server.address(); + if (!addr || typeof addr === "string") throw new Error("no addr"); + await fetch(`http://127.0.0.1:${addr.port}/hook`, { method: "POST" }); + expect(handlerSpy).toHaveBeenCalled(); + abort.abort(); + }); +});