I-041 — a captured note leads nowhere: the subsidiary picker never appears¶
✅ Attestation: read AGENTS.md · board checked by scope (T-187 closed and covered batch throughput, not this dead-end; I-037 is prompt fidelity — no dup) · ticket-only fix. Source: EOP Telegram Bot · https://claude.ai/code/session_019z4CYS2vGnufAXZNDmnBJW
Symptom¶
Owner (2026-09-03): "The Telegram bot is not responsive after I have uploaded a bunch of receipt images" and, on follow-up, "I replied 2 of the uploaded images with substantiation notes, but the bot didn't proceed those 2 images further."
What the evidence showed¶
The bot was not dead — it answered every upload. Firestore telegram_pending_receipts holds 34
pendings created 05:45:32–05:46:44Z, each with a promptMessageId, so all 34 note prompts were
accepted by Telegram. All 37 webhook requests returned 200 and zero /api/inngest invocations
occurred in 24h (correct: nothing dispatches until a subsidiary is chosen).
The fault is at note-capture. Exactly three pendings were written late — 05:46:45, 05:47:04, 05:47:45
— each acquiring a note and awaiting: subsidiary; Vercel logged exactly three
[tg] editMessageCaption failed … "Bad Request: message can't be edited" on the same requests
(05:46:44 / 05:47:03 / 05:47:43). So: note saved → picker edit refused → failure swallowed by
tgEditMessageCaption (it only logged) → no picker, no message, no next step. From the owner's
seat the bot goes silent the moment they answer. Nothing was ever dispatched.
Why the edit is refused (leading explanation, not proven against the live API): the captionless
prompt is a photo sent with force_reply, and the picker morph tries to attach an inline keyboard
to it by edit; Telegram refuses that conversion. The 48h edit window would refuse it too. The same
signature appears once on 2026-08-25 (chat 5373664683, also stuck at awaiting: subsidiary), so the
dead-end predates this batch. The fix deliberately does not depend on which reason it was.
Only accounts with MULTIPLE accessible subsidiaries reach the picker morph (this owner has four), which is why single-subsidiary testing never surfaced it.
Fix¶
tgEditMessageCaptionnow returnsboolean(and logs themessageId) instead of swallowing.proceedAfterNoteCapturedtreats a failed morph as normal and guarantees a next step: picker → fresh picker message quoting the photo; single-subsidiary status → fresh status message whose id is handed to the worker (otherwise the worker narrates into a message it cannot edit and the user watches silence for the whole pipeline); unsupported-subsidiary notice → fresh message.- Stalled receipts can recover. The T-187 duplicate-delivery guard (
awaiting === 'subsidiary' → return) also made a stall permanent — replying again returned early. The guard now only skips a claim younger thanSTALE_CLAIM_MS(60 s, the redelivery window); an older claim re-shows the picker. Uses the pending doc's ownupdateTime— existing metadata, no Firestore field added, no schema change.
Trade-off accepted: for multi-subsidiary users whose morph is refused, the picker arrives as a second message, partially relaxing T-176's one-message-per-receipt for that step. Correctness over aesthetics — a silent dead-end is worse than an extra message.
Recovery for the 3 stalled receipts¶
They cannot be rescued before this ships (a retry hits the same refused edit). After deploy, replying again to any of those prompts re-shows the picker. Otherwise re-upload.
Verification¶
Full unfiltered fresh-npm ci tsc --noEmit --incremental false → exit 0; npm run test 883/887
(the 4 failures are the pre-existing env-dependent billing-ingest tests, absent Firestore ADC).
Runtime verification is owner-side after deploy: upload one captionless receipt, reply with a note,
confirm a subsidiary picker appears.
Follow-up (same day): the card now actually morphs — force_reply removed¶
The owner restated the T-176 requirement — "when I reply the image requiring substantiation note, the image edits itself and provides the next step… one single image for one receipt till the end of it" — which the fallback above does not deliver: it only stops the dead-end, at the cost of a second message.
Root cause, now confirmed against the Bot API documentation: "It is currently only possible to
edit messages without reply_markup or with inline keyboards." The captionless card was sent with
force_reply (to auto-open the reply box), which is a ForceReply markup — so that message was
permanently uneditable. Not just the picker: the live status frames and the final summary morph
would all have been refused too. T-176's one-message card could never have worked on this path.
Fix: sendPhotoNotePrompt sends the card with no reply_markup, so every later stage can morph it
in place. The caption now tells the user to swipe/long-press → Reply. Cost: the reply box no longer
opens by itself — accepted, because a card that cannot be edited cannot carry the receipt at all, and
in a batch force_reply was near-useless anyway (a client can only honour it for the newest message).
The fallback above stays as a safety net for edits refused for other reasons (48h window, deleted
message).
Existing pendings keep their frozen prompts. The 34 cards from the 2026-09-03 batch were sent with force_reply and stay uneditable forever, so answering them will still route through the fallback (picker as a fresh message) — they remain usable. Cards created after this deploys morph properly.
Noted, not fixed (pre-existing)¶
dispatchReceipt still swallows inngest.send failures — a receipt whose event never reaches
Inngest would die silently behind its status message. telegram_pending_receipts also holds 78
pendings back to 2026-05-18 with no cleanup for abandoned ones.