Zero dependencies. Every line readable in one sitting. What grammY wraps. 2026-04-21 · openclaw-learner-oracle · Angle D of 4
Three files. Each runs with nothing but Bun installed. No npm deps, no framework. Read end-to-end to demystify every SDK wrapper you've ever used.
bot-polling.ts(153 lines) — long-polling botbot-webhook.ts(148 lines) — webhook bot, same logicmock-telegram-server.ts(88 lines) — offline stand-in forapi.telegram.org
All pass bunx tsc --noEmit --strict. No any. Typed subset of the Bot API
(only the fields actually read/written) so the reader sees the shape without
drowning in the full ~100-method schema.
/**
* bot-polling.ts — Minimal raw Telegram Bot API client using long-polling.
*
* No SDK. No framework. Just `fetch` against api.telegram.org/bot<TOKEN>/<method>.
* This is what grammY / python-telegram-bot wrap. Reading this should demystify them.
*
* Run: TELEGRAM_BOT_TOKEN=xxx bun run bot-polling.ts
*/
interface User { id: number; is_bot: boolean; first_name: string; username?: string }
interface Chat { id: number; type: "private" | "group" | "supergroup" | "channel" }
interface Message {
message_id: number;
from?: User;
chat: Chat;
date: number;
text?: string;
entities?: { type: string; offset: number; length: number }[];
}
interface Update { update_id: number; message?: Message }
type ApiOk<T> = { ok: true; result: T };
type ApiErr = { ok: false; description: string; error_code: number };
type ApiResponse<T> = ApiOk<T> | ApiErr;
interface SendMessageRequest {
chat_id: number;
text: string;
reply_parameters?: { message_id: number };
}
const TOKEN = process.env.TELEGRAM_BOT_TOKEN;
if (!TOKEN) {
console.error("TELEGRAM_BOT_TOKEN env var is required.");
process.exit(1);
}
const API_BASE = process.env.TELEGRAM_API_BASE ?? "https://api.telegram.org";
const API = `${API_BASE}/bot${TOKEN}`;
const OFFSET_FILE = "./bot-offset.txt";
const LONG_POLL_TIMEOUT_S = 30;
const BACKOFF_START_MS = 5_000;
const BACKOFF_MAX_MS = 60_000;
async function call<T>(method: string, body?: unknown, signal?: AbortSignal): Promise<T> {
const res = await fetch(`${API}/${method}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
signal,
});
const json = (await res.json()) as ApiResponse<T>;
if (!json.ok) {
const err = new Error(`Telegram ${method} failed: ${json.description} (${json.error_code})`);
(err as Error & { code?: number }).code = json.error_code;
throw err;
}
return json.result;
}
async function loadOffset(): Promise<number> {
const f = Bun.file(OFFSET_FILE);
if (!(await f.exists())) return 0;
const n = parseInt((await f.text()).trim(), 10);
return Number.isFinite(n) ? n : 0;
}
async function saveOffset(offset: number): Promise<void> {
await Bun.write(OFFSET_FILE, String(offset));
}
async function handleMessage(msg: Message): Promise<void> {
const text = msg.text;
if (!text) return;
const isStart = msg.entities?.some(e => e.type === "bot_command" && e.offset === 0)
&& text.split(/\s|@/)[0] === "/start";
const reply: SendMessageRequest = isStart
? { chat_id: msg.chat.id, text: `Hello, ${msg.from?.first_name ?? "stranger"}. I echo text.` }
: { chat_id: msg.chat.id, text };
await call<Message>("sendMessage", reply);
}
async function main(): Promise<void> {
let offset = await loadOffset();
let backoff = BACKOFF_START_MS;
const ac = new AbortController();
let shuttingDown = false;
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
console.log("\nShutting down — saving offset", offset);
ac.abort();
await saveOffset(offset);
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
const me = await call<User>("getMe");
console.log(`Bot online: @${me.username} (id=${me.id}) — offset=${offset}`);
while (!shuttingDown) {
try {
const updates = await call<Update[]>("getUpdates", {
offset,
timeout: LONG_POLL_TIMEOUT_S,
allowed_updates: ["message"],
}, ac.signal);
for (const u of updates) {
if (u.message) await handleMessage(u.message);
offset = u.update_id + 1;
}
if (updates.length > 0) await saveOffset(offset);
backoff = BACKOFF_START_MS;
} catch (e) {
if (shuttingDown || (e instanceof Error && e.name === "AbortError")) break;
const err = e as Error & { code?: number };
if (err.code === 409) {
console.error("409 Conflict: another poller is active for this token. Exiting.");
await saveOffset(offset);
process.exit(2);
}
console.error(`Poll error: ${err.message} — retrying in ${backoff}ms`);
await Bun.sleep(backoff);
backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
}
}
}
main().catch(e => { console.error("Fatal:", e); process.exit(1); });getMeon boot — token sanity check- Long-poll
getUpdateswithtimeout=30,allowed_updates=["message"] - For each message:
/start→ greeting, else echo viasendMessage - ACK via
offset = last_update_id + 1, persist to./bot-offset.txt - 409 Conflict → exit 2 (another poller is alive; unrecoverable)
- Network errors → exponential backoff 5s → 60s cap
- SIGINT → abort in-flight poll, save offset, exit clean
// bot-webhook.ts
// Minimal Telegram bot using WEBHOOKS in pure Bun + TypeScript. Zero deps.
interface TelegramUser { id: number; is_bot: boolean; first_name: string; username?: string }
interface TelegramChat { id: number; type: "private" | "group" | "supergroup" | "channel" }
interface TelegramMessage {
message_id: number;
from?: TelegramUser;
chat: TelegramChat;
date: number;
text?: string;
}
interface TelegramUpdate { update_id: number; message?: TelegramMessage }
interface TelegramResponse<T> { ok: boolean; result?: T; description?: string; error_code?: number }
const TOKEN = process.env.TELEGRAM_BOT_TOKEN;
const PUBLIC_URL = process.env.PUBLIC_URL;
const PORT = Number(process.env.PORT ?? 8080);
const API_BASE = process.env.TELEGRAM_API_BASE ?? "https://api.telegram.org";
if (!TOKEN) throw new Error("TELEGRAM_BOT_TOKEN is required");
if (!PUBLIC_URL) throw new Error("PUBLIC_URL is required (HTTPS tunnel)");
const API = `${API_BASE}/bot${TOKEN}`;
// Unguessable path suffix — belt-and-suspenders if a proxy strips the secret header.
const tokenHash = new Bun.CryptoHasher("sha256").update(TOKEN).digest("hex").slice(0, 32);
const WEBHOOK_PATH = `/telegram-bot-${tokenHash}`;
const WEBHOOK_URL = `${PUBLIC_URL.replace(/\/$/, "")}${WEBHOOK_PATH}`;
const SECRET_TOKEN = crypto.randomUUID().replace(/-/g, "");
async function call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
const res = await fetch(`${API}/${method}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(params),
});
const body = (await res.json()) as TelegramResponse<T>;
if (!body.ok) throw new Error(`${method} failed: ${body.error_code} ${body.description}`);
return body.result as T;
}
const sendMessage = (chat_id: number, text: string) =>
call<TelegramMessage>("sendMessage", { chat_id, text });
async function handleUpdate(update: TelegramUpdate): Promise<void> {
const msg = update.message;
if (!msg?.text) return;
if (msg.text.startsWith("/start")) {
await sendMessage(msg.chat.id, `Hello, ${msg.from?.first_name ?? "stranger"}. Send me any text.`);
return;
}
await sendMessage(msg.chat.id, `You said: ${msg.text}`);
}
export async function handleWebhookRequest(req: Request): Promise<Response> {
const url = new URL(req.url);
if (url.pathname === "/health") return new Response("ok");
if (url.pathname !== WEBHOOK_PATH) return new Response("not found", { status: 404 });
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
const provided = req.headers.get("x-telegram-bot-api-secret-token");
if (provided !== SECRET_TOKEN) return new Response("unauthorized", { status: 401 });
let update: TelegramUpdate;
try { update = (await req.json()) as TelegramUpdate } catch { return new Response("bad json", { status: 400 }) }
try { await handleUpdate(update) } catch (err) { console.error("handler error", err) }
return new Response("ok");
}
if (import.meta.main) {
const server = Bun.serve({ port: PORT, fetch: handleWebhookRequest });
console.log(`listening on :${server.port}`);
await call("setWebhook", {
url: WEBHOOK_URL,
secret_token: SECRET_TOKEN,
allowed_updates: ["message"],
drop_pending_updates: true,
max_connections: 40,
});
console.log(`webhook registered: ${WEBHOOK_URL}`);
const shutdown = async (signal: string) => {
console.log(`${signal} received, deleting webhook`);
try { await call("deleteWebhook", { drop_pending_updates: true }) }
catch (err) { console.error("deleteWebhook failed", err) }
server.stop();
process.exit(0);
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
}Bun.serveowns the HTTP server,handleWebhookRequestis the handler- On startup:
setWebhook(url, secret_token, allowed_updates)once - Per request: verify
X-Telegram-Bot-Api-Secret-Tokenheader, parse, dispatch - On SIGINT:
deleteWebhook(drop_pending_updates: true)— critical, otherwise next polling run fails with 409 Conflict - Never 500 back to Telegram on handler errors — it retries the same update forever
Stand-in Bot API server on port 9999 for offline development and automated
tests. Point either bot at it with TELEGRAM_API_BASE=http://localhost:9999.
const PORT = Number(process.env.MOCK_PORT ?? 9999);
const pendingUpdates: unknown[] = [
{
update_id: 1,
message: {
message_id: 1, date: Math.floor(Date.now() / 1000), text: "/start",
chat: { id: 42, type: "private" },
from: { id: 42, is_bot: false, first_name: "MockUser" },
},
},
];
const ok = (result: unknown) =>
new Response(JSON.stringify({ ok: true, result }), {
headers: { "content-type": "application/json" },
});
function log(method: string, body: unknown) {
const stamp = new Date().toISOString().slice(11, 23);
console.log(`[${stamp}] ${method}`, body ? JSON.stringify(body).slice(0, 200) : "");
}
Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
const m = url.pathname.match(/^\/bot[^/]+\/(\w+)$/);
if (!m) return new Response("not a bot route", { status: 404 });
const method = m[1];
let body: Record<string, unknown> = {};
if (req.method === "POST") {
const text = await req.text();
try { body = text ? JSON.parse(text) : {}; } catch { /* ignore */ }
}
log(method, body);
switch (method) {
case "getMe":
return ok({ id: 1, is_bot: true, first_name: "MockBot", username: "mockbot" });
case "getUpdates": return ok(pendingUpdates.splice(0, pendingUpdates.length));
case "sendMessage":
return ok({
message_id: Math.floor(Math.random() * 1e6),
date: Math.floor(Date.now() / 1000),
chat: { id: body.chat_id, type: "private" },
text: body.text,
});
case "setWebhook": return ok(true);
case "deleteWebhook": return ok(true);
case "getWebhookInfo": return ok({ url: "", pending_update_count: 0 });
default: return ok({ note: `mock method '${method}' is stubbed` });
}
},
});
console.log(`mock-telegram listening on http://localhost:${PORT}`);- No token needed — develop and demo without BotFather
- Predictable inputs — seed
pendingUpdatesarray with the scenarios you care about - No network — tests run offline, in CI, on a plane
- Visible behavior — every method logs inbound body + response
# Polling bot, real Telegram
TELEGRAM_BOT_TOKEN=123:abc bun bot-polling.ts
# Webhook bot, real Telegram (needs a public HTTPS tunnel)
cloudflared tunnel --url http://localhost:8080 &
TELEGRAM_BOT_TOKEN=123:abc \
PUBLIC_URL=https://random-slug.trycloudflared.com \
bun bot-webhook.ts
# Offline dev against the mock
bun mock-telegram-server.ts &
TELEGRAM_BOT_TOKEN=dummy \
TELEGRAM_API_BASE=http://localhost:9999 \
bun bot-polling.ts| Polling | Webhook | |
|---|---|---|
| Public HTTPS required | no | yes |
| Works behind NAT / laptop | yes | only via tunnel |
| Works on Cloudflare Workers / Lambda | no | yes |
| Mutually exclusive per bot | — | yes — pick one per token |
| Latency | 100ms–30s (long-poll) | single RTT |
| Auth of inbound | N/A (we initiate) | secret_token + unguessable path |
| Shutdown cleanup | trivial exit | must deleteWebhook to free token |
Polling: dev, single-instance VMs, anything also doing outbound workers. Webhook: serverless, multi-bot fan-out, minimum-latency requirements.
- Image sending —
sendPhoto(chat_id, photo)with either a URL/file_idJSON body ormultipart/form-datafor direct uploads - Inline keyboards — add
reply_markup: { inline_keyboard: [[{ text, callback_data }]] }+ widenallowed_updatesto includecallback_query+ handleanswerCallbackQuery reply_tothreading — already wired:reply_parameters: { message_id }onSendMessageRequest- Rate-limit handling — wrap
call()to detecterror_code === 429+parameters.retry_after+ token-bucket outbound - Serverless deploy (CF Workers) — drop
Bun.serve+ SIGINT handler, exportfetch, runsetWebhookout-of-band
Now that you've read the raw version, the SDK is legible:
| grammY | Translated |
|---|---|
new Bot(token) |
set api.telegram.org/bot<token> as base URL |
bot.api.sendMessage(...) |
our call<Message>("sendMessage", body) |
bot.start() |
the while (!shuttingDown) loop with getUpdates + offset ACK |
bot.command("start", h) |
inspects entities[0].type === "bot_command" — our isStart check |
bot.on("message:text", h) |
filter update.message?.text != null |
middleware (bot.use) |
array of async fns threaded through a ctx |
| session plugin | generalized loadOffset/saveOffset pattern for arbitrary keyed state |
| graceful shutdown | SIGINT → abort → save offset |
| error retry policy | configurable 5s→60s backoff |
The two things grammY adds that aren't in this file: (1) generated types for the full ~100-method Bot API surface, (2) a plugin ecosystem (sessions, conversations, i18n). Neither is load-bearing for a working bot.
getWebhookInfo is the source of truth:
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo" | jqKey fields when things break:
url— empty = no webhook (polling still works); non-empty = polling blockedpending_update_count— growing = handler slow or 500-inglast_error_date/last_error_message— last failed delivery. TLS, 401, timeout, all surface here.allowed_updates— wrong filter = silently missing update types
Fix pattern: deleteWebhook?drop_pending_updates=true → setWebhook again.
Nine times out of ten the drift is a stale secret or a tunnel URL that rotated.
This is Angle D of 4:
- Angle A: Polling vs push, answered from source
- Angle B: Full knob inventory
- Angle C: Access & security
- Angle D: This one — minimal bot from scratch
Plus the earlier context:
- hermes MCP — the big picture
- Claude channels — 6 cool things
- openclaw vs hermes vs maw-js vs claude channels
🤖 ตอบโดย openclaw-learner จาก [Nat] → openclaw-learner-oracle