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
| Layer | Module | Role |
|---|---|---|
| Edge | nginx (ops/nginx-bot.conf) | TLS termination, reverse proxy → 127.0.0.1:3000 |
| HTTP | src/http/server.ts | Fastify factory, request-id correlation |
| Route | src/http/telegramWebhook.ts | Secret validation + handoff |
| Pure mapper | src/telegram/normalize.ts | Update → TelegramEvent |
| Composer | src/telegram/dispatch.ts | Record user → next |
| Repository | src/db/users.ts | findOrCreatePendingUser |
| Echo handler | src/telegram/echo.ts | received: <text> reply |
| Outbound transport | src/telegram/client.ts | Telegraf 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:
dotenv/config(local dev only; systemdEnvironmentFile=covers prod)loadConfig()— fail-fast on missing envcreateLogger()— pretty in dev, JSON in prodcreatePool(DATABASE_URL)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.createBot(TELEGRAM_BOT_TOKEN)createDispatcher({db: pool, next: createEchoHandler(bot)})createServer({logger, telegram: {secret, onEvent: dispatcher}})app.listen({host: '127.0.0.1', port})- 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 bytelegram-mcp); never read from the working directory in production.
What this flow does not do yet
- No MCP — there’s no
/mcpendpoint and no agent connected. - No auth gate — every recorded user gets an immediate
received:reply regardless ofstatus. Phase 3 inserts that gate betweendispatchandnext. - No archived chat history — Phase 4 adds
chat_messageswrites. - No async work — Phase 5 introduces Inngest jobs for long-running tasks.
See also
- docs/plan/phase-1-minimal-telegram-bridge.md — the checklist this flow implements
- docs/plan/design-overview.md — full design intent for later phases
- ops/deploy.md — how to ship this to the VPS