Skip to Content
Telegram MCPArchitecture1 — Inbound

Phase 1 — Inbound message flow

Architecture-as-built snapshot for the only request lifecycle Phase 1 ships: a Telegram update arrives at the bot’s webhook → the server records the user → the static echo handler replies. No MCP, no Inngest, no agent yet.

Components in play

LayerModuleRole
Edgenginx (ops/nginx-bot.conf)TLS termination, reverse proxy → 127.0.0.1:3000
HTTPsrc/http/server.tsFastify factory, request-id correlation
Routesrc/http/telegramWebhook.tsSecret validation + handoff
Pure mappersrc/telegram/normalize.tsUpdate → TelegramEvent
Composersrc/telegram/dispatch.tsRecord user → next
Repositorysrc/db/users.tsfindOrCreatePendingUser
Echo handlersrc/telegram/echo.tsreceived: <text> reply
Outbound transportsrc/telegram/client.tsTelegraf sendMessage

Flow

Telegram │ POST https://bot.app.deneva.io/telegram/webhook │ X-Telegram-Bot-Api-Secret-Token: <secret> nginx (443 → 127.0.0.1:3000) Fastify route POST /telegram/webhook ├─ no header / mismatched secret ─► 401 + warn log │ (constant-time SHA-256 compare) normalize(update): Update → TelegramEvent | null ├─ null (edited / channel / sticker / poll / …) ─► 200 ok dispatcher(event): ├─ findOrCreatePendingUser(pool, {userId, username, firstName}) │ │ │ └─ INSERT … ON CONFLICT DO UPDATE (COALESCE on username/first_name) └─ echoHandler(event) └─ if event.kind === 'text': bot.telegram.sendMessage(chatId, `received: ${text}`) 200 { ok: true }

Boot order

src/index.ts executes:

  1. dotenv/config (local dev only; systemd EnvironmentFile= covers prod)
  2. loadConfig() — fail-fast on missing env
  3. createLogger() — pretty in dev, JSON in prod
  4. createPool(DATABASE_URL)
  5. bootstrapAdmin(pool, ADMIN_TELEGRAM_USER_ID) — must finish before the listener binds, otherwise the admin’s first message could land in an unbootstrapped DB and we’d treat them as a pending user.
  6. createBot(TELEGRAM_BOT_TOKEN)
  7. createDispatcher({db: pool, next: createEchoHandler(bot)})
  8. createServer({logger, telegram: {secret, onEvent: dispatcher}})
  9. app.listen({host: '127.0.0.1', port})
  10. Register SIGINT/SIGTERM → close Fastify, then pool.end()

Observability

Every request emits exactly one canonical log line via the onResponse hook in src/http/server.ts:

{ requestId, route, statusCode, durationMs, outcome }

outcome is ok (2xx), rejected (4xx), or error (5xx). Handler logs emitted via request.log share the same requestId, so a journalctl query on a single ID returns the full request tape.

Security boundaries

  • Node listens on loopback only (127.0.0.1:3000). The process is unreachable from the internet directly; nginx is the only ingress.
  • Webhook authentication is constant-time SHA-256 + timingSafeEqual, not a string ===. See http-telegramWebhook.
  • Secrets live in /etc/telegram-mcp/env (mode 600, owned by telegram-mcp); never read from the working directory in production.

What this flow does not do yet

  • No MCP — there’s no /mcp endpoint and no agent connected.
  • No auth gate — every recorded user gets an immediate received: reply regardless of status. Phase 3 inserts that gate between dispatch and next.
  • No archived chat history — Phase 4 adds chat_messages writes.
  • No async work — Phase 5 introduces Inngest jobs for long-running tasks.

See also