Telegram: enable grammY throttler and webhook tests

This commit is contained in:
Peter Steinberger
2025-12-07 22:52:57 +01:00
parent 4d3d9cca2a
commit 5f5846a08b
7 changed files with 120 additions and 6 deletions

View File

@@ -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. - Launchd PATH and helper lookup hardened for packaged macOS builds; health probes surface missing binaries quickly.
### Docs ### Docs
- Added `docs/telegram.md` outlining the upcoming Telegram Bot API provider (grammY-based) and how it will share the `main` session. - 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 now exposes `relay:telegram` and text/media sends via `--provider telegram`; typing/webhook still pending. - CLI exposes `relay:telegram` (grammY) and text/media sends via `--provider telegram`; webhook/proxy options documented.
## 1.5.0 — 2025-12-05 ## 1.5.0 — 2025-12-05

View File

@@ -101,7 +101,7 @@ Create `~/.clawdis/clawdis.json`:
- [Security](./docs/security.md) - [Security](./docs/security.md)
- [Troubleshooting](./docs/troubleshooting.md) - [Troubleshooting](./docs/troubleshooting.md)
- [The Lore](./docs/lore.md) 🦞 - [The Lore](./docs/lore.md) 🦞
- [Telegram (Bot API) — WIP](./docs/telegram.md) - [Telegram (Bot API)](./docs/telegram.md)
## Clawd ## Clawd
@@ -120,8 +120,8 @@ clawdis login # Scan QR code
clawdis relay # Start listening clawdis relay # Start listening
``` ```
### Telegram (Bot API) — WIP ### Telegram (Bot API)
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. 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 ## Commands

22
docs/research/grammy.md Normal file
View File

@@ -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 grammYs `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:<chatId>`; 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`).

View File

@@ -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. - Typing indicators (`sendChatAction`) supported; inline reply/threading supported where Telegram allows.
## Planned implementation details ## 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. - 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. - 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. - Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.requireMention`, `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl` supported.

33
src/telegram/bot.test.ts Normal file
View File

@@ -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");
});
});

View File

@@ -1,6 +1,7 @@
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import { Bot, InputFile, webhookCallback } from "grammy"; import { Bot, InputFile, webhookCallback } from "grammy";
import { apiThrottler } from "@grammyjs/transformer-throttler";
import type { ApiClientOptions } from "grammy"; import type { ApiClientOptions } from "grammy";
import { chunkText } from "../auto-reply/chunk.js"; import { chunkText } from "../auto-reply/chunk.js";
@@ -38,6 +39,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
: undefined; : undefined;
const bot = new Bot(opts.token, { client }); const bot = new Bot(opts.token, { client });
bot.api.config.use(apiThrottler());
const cfg = loadConfig(); const cfg = loadConfig();
const requireMention = const requireMention =

View File

@@ -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();
});
});