src/telegram/dispatch.ts — Inbound event composer
Builds the Phase 1 inbound pipeline: record the user, then run the next handler.
Public surface
| Export | Shape | Purpose |
|---|---|---|
createDispatcher(deps) | (DispatchDeps) => (TelegramEvent) => Promise<void> | Builder |
DispatchDeps | { db: Queryable, next: (event) => Promise<void> } | DI |
Behavior
findOrCreatePendingUser(db, {telegramUserId, username, firstName})— upserts the row. Idempotent for repeat messages; refreshesusername/first_nameif Telegram supplied new values.- If the upsert succeeds, call
next(event). The bootstrapped admin row is not clobbered —bootstrapAdminandfindOrCreatePendingUseruse compatibleON CONFLICTclauses that only touch the fields each one owns. - If the upsert throws, the error propagates out. The webhook handler
catches it and logs without re-throwing (see
telegramWebhook) —
nextis not invoked, so the user never receives a reply.
Why this composer exists
The user-recording step has to run for every inbound event, regardless
of kind. Inlining it into the echo handler would mean Phase 3’s auth gate
and Phase 6’s agent forwarder each need to re-implement it. Splitting it as
a composer keeps the “always record” guarantee in one place and lets later
phases swap out next without losing it.
Phase 3 extension point
Phase 3 inserts an auth gate between the upsert and next:
record → check status → (active? → next) | (pending? → "approval pending" reply) | (blocked? → drop)The composer’s shape is intentional: it leaves room for that branching without restructuring the call sites.
Tests
test/unit/dispatch.test.ts — 4 cases:
- Ordering: db before next (proven via call-order log)
nullusername/firstName passes through unchanged- Non-text events (e.g. photo) still touch the table
- DB failure short-circuits —
nextis never called