Skip to content

Instantly share code, notes, and snippets.

@nazt
Created April 21, 2026 01:17
Show Gist options
  • Select an option

  • Save nazt/cd627bd0f99d6d933be5032232c52a93 to your computer and use it in GitHub Desktop.

Select an option

Save nazt/cd627bd0f99d6d933be5032232c52a93 to your computer and use it in GitHub Desktop.
Minimal Telegram bot from scratch — polling + webhook + mock, in Bun/TS, zero deps (Angle D of 4)

A minimal Telegram bot from scratch — polling + webhook + mock, in Bun/TS

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 bot
  • bot-webhook.ts (148 lines) — webhook bot, same logic
  • mock-telegram-server.ts (88 lines) — offline stand-in for api.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

/**
 * 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); });

What this does

  • getMe on boot — token sanity check
  • Long-poll getUpdates with timeout=30, allowed_updates=["message"]
  • For each message: /start → greeting, else echo via sendMessage
  • 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

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

What this does (and what differs from polling)

  • Bun.serve owns the HTTP server, handleWebhookRequest is the handler
  • On startup: setWebhook(url, secret_token, allowed_updates) once
  • Per request: verify X-Telegram-Bot-Api-Secret-Token header, 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

mock-telegram-server.ts

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}`);

Why a mock matters

  • No token needed — develop and demo without BotFather
  • Predictable inputs — seed pendingUpdates array with the scenarios you care about
  • No network — tests run offline, in CI, on a plane
  • Visible behavior — every method logs inbound body + response

Running the three pieces

# 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 vs. webhook — when to use which

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.

What would change to add…

  • Image sendingsendPhoto(chat_id, photo) with either a URL/file_id JSON body or multipart/form-data for direct uploads
  • Inline keyboards — add reply_markup: { inline_keyboard: [[{ text, callback_data }]] } + widen allowed_updates to include callback_query + handle answerCallbackQuery
  • reply_to threading — already wired: reply_parameters: { message_id } on SendMessageRequest
  • Rate-limit handling — wrap call() to detect error_code === 429 + parameters.retry_after + token-bucket outbound
  • Serverless deploy (CF Workers) — drop Bun.serve + SIGINT handler, export fetch, run setWebhook out-of-band

What grammY's bot.start() does under the hood

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.

Debugging webhooks — the one command

getWebhookInfo is the source of truth:

curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo" | jq

Key fields when things break:

  • url — empty = no webhook (polling still works); non-empty = polling blocked
  • pending_update_count — growing = handler slow or 500-ing
  • last_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=truesetWebhook again. Nine times out of ten the drift is a stale secret or a tunnel URL that rotated.


The whole Telegram configuration series

This is Angle D of 4:

Plus the earlier context:

🤖 ตอบโดย openclaw-learner จาก [Nat] → openclaw-learner-oracle

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment