Skip to content

Repository files navigation

Telegram Serverless Bot Template

An educational template for Telegram Serverless — Telegram's own platform that runs your bot's backend directly on their infrastructure. You write plain JavaScript modules; the platform runs them in a V8 sandbox next to the Bot API, with a built-in SQLite database. No servers, no containers, no webhook plumbing.

The template is a small but complete todo bot: send it text to add a task, /list to see your tasks as tappable buttons, tap to toggle or delete. Small on purpose — every file exists to demonstrate one part of the platform, and each folder has its own README explaining its role:

The main principles

1. Your bot is a set of event handlers, not a server

There is no main(), no process, no event loop of yours. Telegram delivers each update (a message, a button tap, a member change) to the file in handlers/ named after the update's type — handlers/message.js for messages, handlers/callback_query.js for button taps. Each file default-exports one async function; when it resolves, the invocation is over.

The webhook is managed for you and derived from your code: the platform builds allowed_updates from which handler files exist. Handling a new update type is creating a file, not configuring anything.

flowchart LR
    U([User]) -->|message, button tap, …| TG[Telegram]
    TG -->|Update| R{{"platform routes by update type"}}
    R -->|update.message| M["handlers/message.js"]
    R -->|update.callback_query| C["handlers/callback_query.js"]
    M --> DB[("built-in SQLite")]
    C --> DB
    M -.->|"api.sendMessage(…)"| TG
    C -.->|"api.editMessageText(…)"| TG
Loading

2. Everything is a module, and the sandbox is not Node

Your code runs in a V8 isolate with exactly three kinds of imports, all by bare name — never relative paths, never .js extensions:

import { db, api, fetch } from 'sdk';   // the platform: database, Bot API, HTTP
import { todos } from 'schema';         // your schema (root file)
import { addTodo } from 'lib/todos';    // your shared modules

There are no npm packages at runtime, no filesystem, and no network except fetch from the SDK. This sounds restrictive; it's what makes deploys instant, cold starts negligible, and every module trivially auditable. (The @tgcloud/cli in package.json is a local dev tool — it never ships.)

3. State lives in the built-in database

Handlers are stateless between invocations — a global variable won't survive. Persistent state goes in the SQLite database that ships with every bot, declared in schema.js as tables and queried with a Drizzle-style builder:

await db.insert(todos).values({ chatId, text }).returning().run();
await db.select().from(todos).where(eq(todos.chatId, chatId)).all();

Two rules that bite: every DB call must be awaited (a forgotten await returns the query builder, not rows), and there are no foreign keys.references() throws at deploy time, so integrity is your application code's job (see how lib/todos.js scopes every query by chatId).

4. Code and schema change on different rhythms

npx tgcloud push deploys your modules atomically — and never touches the database. Schema changes are a separate, explicit step:

npx tgcloud push       # deploy code; reports pending DB changes, applies none
npx tgcloud migrate    # apply schema.js changes to the database
flowchart LR
    subgraph local["your folder (working copy)"]
        H["handlers/ + lib/"]
        S["schema.js"]
    end
    subgraph cloud["your bot's cloud environment"]
        D["deployed modules"]
        P["pending schema changes"]
        DB[("database")]
    end
    H -->|"push (atomic)"| D
    S -->|push| P
    P -->|"migrate (explicit)"| DB
Loading

Destructive changes are deliberately hard: dropping a column or table happens only by marking it .deprecated('reason') in schema.js — deleting the declaration drops nothing. This asymmetry (code moves fast, data moves carefully) is the same discipline production teams enforce by convention; here the platform enforces it.

5. The cloud is a shared truth you sync with

The deployed bot is the source of truth; your folder is a working copy. push is rejected if someone else deployed since you last synced (like a git push to a moved branch) — status, diff, fetch, and pull reconcile, --force overwrites deliberately. The .tgcloud/ folder is the CLI's private state for all this: gitignored, never edited by hand.

Quick start

  1. Create a bot with @BotFather (or pick an existing one) and enable Serverless for it.

  2. Link and deploy:

    npm install
    npx tgcloud login      # links this folder to your bot
    npx tgcloud push       # deploy the modules
    npx tgcloud migrate    # create the tables
  3. Message your bot: any text adds a todo, /list shows the list, buttons toggle/delete.

A guided tour (suggested reading order)

  1. schema.js — two tables, an index, timestamp defaults, and why there's no foreign key.
  2. handlers/message.js — command routing, including the /cmd@botname form used in groups.
  3. lib/todos.js — the queries, and renderList(): one function that both handlers use to draw the list, embedding toggle:<id> / delete:<id> into the buttons' callback_data.
  4. handlers/callback_query.js — the other side of that contract: answer the callback, mutate, re-render the message in place, and swallow exactly one expected Bot API error.
  5. lib/users.js — an idempotent upsert (onConflictDoUpdate).

Then skim docs/tgcloud-sdk.md for the full API surface, and AGENTS.md for the condensed rules (that file is auto-loaded by AI coding tools working in this repo).

Everyday commands

npx tgcloud status               # what changed locally vs the cloud
npx tgcloud diff                 # line-by-line diff of changed modules
npx tgcloud push                 # deploy (rejects if the cloud moved; --force overrides)
npx tgcloud migrate              # apply schema changes (--dry-run to preview)
npx tgcloud run handlers/message '{ chat: { id: 1 }, text: "hi" }'
                                 # execute a handler server-side with a fake payload
npx tgcloud add handlers/inline_query   # scaffold a new handler
npx tgcloud webhook              # inspect webhook state; `webhook sync` repairs drift

npm shortcuts: npm run deploy, npm run status, npm run migrate.

Platform limits worth knowing up front

  • Bot API calls via api.<method>() return the unwrapped result and throw BotApiError on failure (.code, .description, .parameters — e.g. retry_after on a 429).
  • File bytes can't be downloaded or uploaded from a handler yet — pass file_ids around instead.
  • fetch responses are textual only, capped at 32 MB total.
  • fetch never throws on HTTP error statuses — a 404 resolves normally with res.ok === false; only real network failures (bad host, invalid URL) reject. Redirects are followed automatically (res.url is the final URL), and a body can be read once — a second .json()/.text() throws.
  • A plain insert/update/delete resolves to [] — there's no insert id or affected-row count. Add .returning() when you need the rows back (as lib/todos.js does).
  • Raw SQL (db.run/db.all/db.get) skips column mode conversion: booleans arrive as 0/1, timestamps as unix seconds, json as strings. Only the table-bound query builder converts.
  • Deployed surface is exactly: schema.js, .js files in lib/ and handlers/. Markdown (including every README here), dotfiles, and .tgcloud/ stay local.

Contributing

See CONTRIBUTING.md. Bugs and ideas go in issues. Security reports are private: see SECURITY.md.

License

MIT. See LICENSE.

About

Educational template for Telegram Serverless: a todo bot demonstrating update handlers, inline keyboards, the built-in SQLite database, and the tgcloud deploy/migrate workflow.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

159 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages