Phase 1 — Minimal Telegram MCP Bridge
Goal
Telegram message arrives → server logs it, sends a hardcoded reply. No MCP, no DB beyond the user table stub, no auth beyond hardcoded admin. The goal of this phase is to prove the HTTP + Telegram + Postgres + systemd pipeline end-to-end before adding any real protocol surface.
Status (2026-05-14)
All scope and tests are landed. Definition of Done items 1–4 are met in code; final manual verification of items 1–3 (live /start round-trip) is pending the next VPS deploy.
Scope / Deliverables
Scaffolding:
-
npm init, TypeScript + strict mode,tsconfig, Vitest, ESLint, Prettier - Fastify HTTP server bound to
127.0.0.1:PORT -
config.ts— Zod-parsed env loader; fail-fast on missing required vars -
pinologger withrequestIdplugin -
GET /healthzreturning{ ok: true }
Telegram side:
- telegraf bot instance constructed from
TELEGRAM_BOT_TOKEN(src/telegram/client.ts) -
POST /telegram/webhookhandler validatesX-Telegram-Bot-Api-Secret-Token(src/http/telegramWebhook.ts) -
normalize.tsconverts Telegram updates →TelegramEventunion (text/photo/document/callback) (src/telegram/normalize.ts) - Static echo: any text message → reply “received:
” ( src/telegram/echo.ts) -
npm run register-webhookscript: calls TelegramsetWebhookwithPUBLIC_BASE_URLand the secret (scripts/register-webhook.ts)
Persistence (minimal):
-
node-pg-migratewired up - Migration:
userstable (full schema from data-and-contracts.md) - On first inbound update from a new user, insert with
status='pending'(viasrc/telegram/dispatch.ts→findOrCreatePendingUser) -
ADMIN_TELEGRAM_USER_IDuser auto-inserted withstatus='active', role='admin'on boot (bootstrapAdmin, called fromsrc/index.ts)
Ops:
- nginx server block (
ops/nginx-bot.conf) forbot.app.deneva.io; cert viacertbot --nginx - systemd unit committed to
ops/telegram-mcp.service - Deploy runbook (
ops/deploy.md) drafted
Design adjustments made during implementation
These are deviations from the original scope, noted for traceability:
- TelegramEvent envelope enriched. The phase doc listed
chatId, userId, textfor text events andchatId, userId, callbackDatafor callbacks. The implementation also carriesusername,firstName, andmessageIdon every event kind. Rationale: theuserstable already has columns for username/first_name, andmessageIdis needed by Phase 7’sanswerCallbackQueryflow. Documented in components/telegram-normalize.md. - Webhook returns 200 even when the handler throws. Intentional — Telegram redelivers on non-2xx, and a deterministic downstream bug would otherwise loop forever. Transient failures will be retried via Inngest in Phase 5. Documented in components/http-telegramWebhook.md.
allowed_updateswhitelist sent during webhook registration restricts Telegram tomessageandcallback_querydeliveries, sonormalize()’s drop paths exist mostly for defense-in-depth.- Architecture-as-built documented at architecture/phase-1-inbound.md with the full request lifecycle and boot order.
Automated Unit Testing
All landed and green (npm test → 41 passed, 6 skipped; the 6 skipped are DB-gated and run when Postgres is reachable):
-
config.test.ts— Zod env loader: rejects missing required vars with a clear error; accepts a valid env; appliesPORT=3000default; coerces numeric vars correctly. (file) -
normalize.test.ts— Telegram update fixtures map correctly:- text update →
TelegramEvent { kind: 'text', text, chatId, userId, username, firstName, messageId } - photo update →
TelegramEvent { kind: 'photo', fileId of largest size, dimensions } - document update →
TelegramEvent { kind: 'document', fileId, mimeType, size } - callback query →
TelegramEvent { kind: 'callback', callbackData, callbackQueryId, chatId, userId } - drop paths: edited_message, channel_post, sticker, poll, game callback, inaccessible callback, inline-mode callback (file)
- text update →
-
webhook.test.ts—POST /telegram/webhook:- missing
X-Telegram-Bot-Api-Secret-Token→ 401, no side effects - mismatched secret → 401, attempt is logged
- matching secret → 200, normalized event forwarded
- ignored update kind → 200, handler not invoked
- handler throws → still 200 + error log (file)
- missing
-
healthz.test.ts—GET /healthzreturns{ ok: true }with content-typeapplication/json. (file) -
users.repo.test.ts(transactional, against the docker-compose DB):- first inbound update from an unknown
telegram_user_idinserts a row withstatus='pending', role='user' - a second update for the same user is idempotent (no duplicate inserts)
bootstrapAdmininsertsADMIN_TELEGRAM_USER_IDwithstatus='active', role='admin'; re-running is a no-op; promotes a pre-existing pending row in place (file)
- first inbound update from an unknown
-
logger.test.ts— every request emits one summary log line containing{requestId, route, durationMs, outcome}; child logs share the samerequestId. (file) -
dispatch.test.ts(added during implementation — composition test forsrc/telegram/dispatch.ts): db-then-next ordering, null name passthrough, non-text events still touch the table, DB failure short-circuitsnext. (file)
Integration scope (deferred to integration suite): full setWebhook round-trip via a captured Telegram dev bot is out of scope for unit tests — exercised manually via npm run register-webhook.
Definition of Done
- Send
/startfrom your personal account to the bot → reply arrives within 2s. (pending next VPS deploy — code path is unit-tested + dispatch wiring matches the runbook) - User row exists in DB with
status='pending'. (pending VPS deploy) - Admin user row exists with
status='active'. (pending VPS deploy) - All requests logged with
requestIdcorrelation. (logger.test.ts proves the contract end-to-end via Fastify’sonResponsehook.)