feat(telegram): inbound attachment support (image vision, documents, audio transcription) - #2201
Conversation
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @chenmingwei23 overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSAdvisory design-level review of Design-Verdict: PASS Right layers throughout: envelope quirks (albums) stay in the client, channel-neutral logic hoisted to Suggestions
[DESIGN-REVIEWED] b552d6a |
Opus 5 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
8dfa00e to
c3eda36
Compare
c3eda36 to
df07f95
Compare
df07f95 to
a266c1f
Compare
|
Dispositions for findings raised on GPT 5.6 / Opus 5 — BLOCKING, GPT 5.6 / Opus 5 — BLOCKING, Design Review (advisory 🟡, non-blocking) — Guarded by a source-level contract test ( Automated Rule Check — Backend Lint & Type Check — isort — FIXED Note on a pre-existing local failure (not introduced here, green in CI) |
|
Design Review (advisory 🟡 suggestion on The observation is correct and I verified it against the code rather than taking it on faith: Worth noting for whoever picks it up: albums sometimes coalesce today, because a member arriving while a turn is running goes through the mid-turn queue and the drain collapses queued messages into one combined turn. That is a race, not the mechanism — it does not apply when the session is idle, so it cannot be relied on. Deferring rather than fixing here, for the reason Design Review itself gave ("fine as a fast follow-up"): single-attachment ingestion is correct and complete without it, the fix lives entirely in Telegram envelope grouping and needs a debounce window plus a bounded buffer to be safe, and folding that into this PR would widen a green diff to satisfy an advisory note. Filed as #2205 with the implementation sketch and the two hazards (unbounded buffer, and how an over-cap album should degrade against No code change in this push; this is the written disposition only. |
688f1b1 to
c49747e
Compare
2986867 to
ede5f5d
Compare
|
BLOCKING — The cap I added last round deferred the overflowing album back onto the queue, but Telegram's Ported Discord's shape properly: The cap test pins both halves, and I reordered its assertions deliberately: cap-per-turn is checked before the turn count, because a cap regression merges both albums into one turn and would otherwise trip the pump-count assertion first, leaving the cap unpinned. Each mutation now fails on its own assertion:
One pre-existing test updated rather than deleted.
Gates on |
Telegram dropped every non-text message (photos, documents, audio, voice) silently in transport.receive(), and delivers a multi-photo album as N separate updates rather than one message. Adds full inbound ingestion and folds an album back into a single turn. Generalized into the shared messaging layer, so wording and handling cannot drift between channels: - append_attachment_context() hoisted from discord/attachments.py - transcribe_audio_attachments() hoisted -- both adapters carried byte-identical STT-availability + transcription loops Telegram-specific: - telegram/attachments.py maps photo/document/audio/voice/video onto the shared Attachment shape - TelegramClient extracts photo[-1] (largest)/document/audio/voice/video/ animation, uses caption as text fallback, and downloads via getFile with host allowlisting, token-free error messages (the URL embeds the bot token), and file I/O offloaded to asyncio.to_thread - TelegramTransport: files_inbound=True, accepts attachment-only messages - TelegramDispatcher ingests after session acquire and propagates attachments through the mid-turn queue and drain Album coalescing (envelope layer only -- Discord and Slack already deliver every attachment in one event, so the shared layer's "one message, N attachments" contract already holds for them): - _dispatch buffers members keyed by (chat_id, media_group_id); keying on the id alone would merge two chats' members into one message and leak content across a conversation boundary - the merge concatenates attachments in order, takes the caption from the first non-empty member, and keeps the head message_id so a reply or steer-ack targets the album's first message - bounded on every axis: per-member rearming window, hard ceiling from the first member, per-group member cap (logged, never silently truncated), concurrent-group cap that force-flushes the oldest rather than dropping, and a best-effort flush on close() Restores two channel parity gaps that album buffering made reachable, both of which silently discarded files on Telegram only: - an attachment message could take the steer path, which forwards text only; it now always takes the queue path, which carries attachments - an attachment caption like "/new" hit the command intercept, which returns before ingestion; attachments now mark the message as content, matching Discord's interpret_as_command Also fixes an order-dependent doctor test this PR's new tests exposed: test_doctor_names_the_missing_native_libs asserts the no-override branch, but LLAMA_CPP_LIB_PATH could arrive from the ambient environment or from the sibling override test, whose helper sets it via a raw os.environ.setdefault that escapes pytest teardown. pytest-split reshuffles shards whenever the suite's test count changes, so adding tests elsewhere flipped which shard saw the leak. The test now clears the var explicitly. Known residual, tracked in #2217: an inbound message arriving inside the shutdown window is refused by SessionManager._closing and lost. This is pre-existing and affects every channel with no attachments involved; buffering widens that window by at most the debounce interval. Documented in code and pinned by test rather than claimed away. Closes #2200 Closes #2205
ede5f5d to
b552d6a
Compare
|
Opus 5 advisory — I had written "the caption rides on exactly one member" and coded Now Pinned by Design Review advisory — duplicated drain/collapse loop — ACCEPTED, tracked in #2304, not doing it here. The observation is right and it lands on my own change: #2201 hoisted It also has the strongest evidence behind it of anything raised on this PR. All three drain defects found during #2201/#2213 review were parity gaps — present in one channel, absent in the other:
A shared implementation makes that class structurally impossible instead of something review has to re-catch per channel. Deferring rather than doing it in this PR: it is a cross-channel refactor of the Discord path, which is live and carries its own test surface, on top of a change that has already grown to 12 files. Behaviour is correct in both channels today and both sides are mutation-verified. #2304 carries the proposed shape and the note that its contract test must be scoped to the function body — a module-scoped Gates on |
|
/ai-review override gpt b552d6a: Pre-existing shutdown loss class, not introduced here -- |
Human judgment recorded@chenmingwei23 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
|
Design Review advisory on Opus is clean on this SHA (no findings — the per-item caption fix closed its only advisory), so this is the last open item and it is non-blocking. I verified the claim rather than just agreeing with it, and it holds precisely: One correction, in the direction of the finding being understated: it frames the risk as "the fourth channel," but there are already 7 dispatchers — And this PR is the evidence for it. Telegram was written by hand-mirroring Discord and still shipped, round by round, missing four gates Discord already had — steer-path gate, command-path gate, collapsed-attachment cap, and pump-until-empty drain. Four for four, each surfaced by a different review round, each a silent drop or silent stall. Hand-mirroring demonstrably does not converge, which is a stronger argument for hoisting than the byte-similarity itself. Not doing it here: it is a cross-channel refactor of the live Discord and Slack paths on top of a change already at 12 files, and behaviour is correct in both channels today with every gate mutation-verified. #2304 now carries the verified line references, the 7-dispatcher scope, the four-gate evidence table, and the note that its contract test must be function-body-scoped. Final state of
|
) The 0.2.0 section was written in #2305, the commit that became v0.2.0-rc.4. Seventy-one commits have landed on main since, nineteen of them feat:, and the section was never revisited. It therefore both omitted shipped features and described one that no longer exists as written. The wrong entry mattered most: the Webhooks bullet told the reader to manage inbound automation "from Settings", but #2343 moved that page behind a per-device Preview pages toggle under Developer and hides it by default. A 0.2.0 user following the release notes would have gone looking for a page that is not there. Added, all from the rc.6 range: opt-in Slack setup and the multi-channel repositioning (#2340), Telegram multi-account (#2203) and inbound attachments (#2201), sub-agent completions reaching non-Slack parents (#2352), Discord reply continuation (#2326), Slack OPTIONS as a control (#1467), the Agent Templates two-pane inspector, project-local agent discovery (#2167), send-a-copy-to-another-instance, Jira and setting link chips (#2019, #1907), CJK emphasis rendering, the MCP Apps switch (#2293, #2337), the Connections provider registry (#2285), GitHub Enterprise Server support in Code Review Sage (#2154), operator notes on user deny patterns (#2341), the locked git-publish floor rules (#2369), the persist-or-refuse boot guard (#2279), and the turn-ceiling bounds on the approval and stall windows (#2372, #2373). Scope is exactly 5fe4bd5..ab20b4e, the range v0.2.0-rc.6 ships. The three commits main carries beyond rc.6, including meeting deletion (#2268), belong to the next release and are deliberately not described here.
…ts, audio transcription) (kirodotdev#2201) Telegram previously dropped all non-text messages (photos, documents, audio, voice) silently in transport.receive(). This adds full inbound attachment ingestion mirroring the existing Discord implementation. Generalized into the shared messaging layer (channel-neutral, so wording and handling cannot drift between channels): - append_attachment_context() hoisted from discord/attachments.py - transcribe_audio_attachments() hoisted -- both adapters had byte-identical STT-availability + transcription loops (flagged by Design Review) Telegram-specific: - telegram/attachments.py: envelope normalization (_to_attachment) mapping photo/document/audio/voice/video onto the shared Attachment shape - TelegramClient: extract photo[-1] (largest)/document/audio/voice/video/ animation from updates, caption as text fallback, download_file() via getFile with host allowlisting, token-free error messages, and file I/O offloaded to asyncio.to_thread so a large attachment cannot stall the gateway event loop - TelegramTransport: files_inbound=True, accept attachment-only messages - TelegramDispatcher: ingest after session acquire, propagate attachments through the mid-turn queue and drain, cleanup in finally Closes kirodotdev#2200
…rodotdev#2412) The 0.2.0 section was written in kirodotdev#2305, the commit that became v0.2.0-rc.4. Seventy-one commits have landed on main since, nineteen of them feat:, and the section was never revisited. It therefore both omitted shipped features and described one that no longer exists as written. The wrong entry mattered most: the Webhooks bullet told the reader to manage inbound automation "from Settings", but kirodotdev#2343 moved that page behind a per-device Preview pages toggle under Developer and hides it by default. A 0.2.0 user following the release notes would have gone looking for a page that is not there. Added, all from the rc.6 range: opt-in Slack setup and the multi-channel repositioning (kirodotdev#2340), Telegram multi-account (kirodotdev#2203) and inbound attachments (kirodotdev#2201), sub-agent completions reaching non-Slack parents (kirodotdev#2352), Discord reply continuation (kirodotdev#2326), Slack OPTIONS as a control (kirodotdev#1467), the Agent Templates two-pane inspector, project-local agent discovery (kirodotdev#2167), send-a-copy-to-another-instance, Jira and setting link chips (kirodotdev#2019, kirodotdev#1907), CJK emphasis rendering, the MCP Apps switch (kirodotdev#2293, kirodotdev#2337), the Connections provider registry (kirodotdev#2285), GitHub Enterprise Server support in Code Review Sage (kirodotdev#2154), operator notes on user deny patterns (kirodotdev#2341), the locked git-publish floor rules (kirodotdev#2369), the persist-or-refuse boot guard (kirodotdev#2279), and the turn-ceiling bounds on the approval and stall windows (kirodotdev#2372, kirodotdev#2373). Scope is exactly 5fe4bd5..ab20b4e, the range v0.2.0-rc.6 ships. The three commits main carries beyond rc.6, including meeting deletion (kirodotdev#2268), belong to the next release and are deliberately not described here.
Problem
Telegram users sending photos, documents, or voice memos got no response:
transport.receive()returned early onif not inbound.text, so every non-text message was dropped silently, with no explanation.Worse for the primary use case — Telegram does not deliver an album as one message. Selecting four screenshots and sending them with one question produces four separate
messageupdates sharing amedia_group_id, with the caption on only one member:Why it matters
Screenshots are the reason to send an image to an assistant at all. Discord already supported inbound attachments; Telegram users were locked out, and multi-image — the ordinary case — was structurally broken rather than merely unsupported.
Fix (symptoms → root cause → change)
Root cause was two layers: nothing extracted file metadata from Telegram updates, and nothing ever looked at
media_group_id(zero references before this change), so the envelope's one-album-is-N-updates shape reached the shared pipeline unmodified.Generalized into the shared layer — so wording and handling cannot drift between channels:
append_attachment_context()hoisted out ofdiscord/attachments.pyintomessaging/attachments.py.transcribe_audio_attachments()hoisted likewise. Both channel adapters carried byte-identical STT-availability + transcription loops (flagged by Design Review); each now ends in one delegating call, so the transcript wording, the STT-unavailable rejection text, and the off-loop availability check exist in exactly one place.Telegram-specific:
telegram/attachments.pymaps photo/document/audio/voice/video onto the sharedAttachmentshape.TelegramClientextractsphoto[-1](largest), document, audio, voice, video, animation; usescaptionas the text fallback; downloads viagetFilewith host allowlisting, token-free error messages (the download URL embeds the bot token, so aiohttp's default exception text would leak it intogateway.log), and file I/O offloaded toasyncio.to_threadso a 20 MB document cannot stall the gateway event loop.TelegramTransport:files_inbound=True, accepts attachment-only messages.TelegramDispatcheringests after session acquire and propagates attachments through the mid-turn queue and drain.Album coalescing — envelope layer only, deliberately not generalized. Discord's
MESSAGE_CREATEcarries the wholeattachments[]array and Slack's message event carriesfiles[], so the shared layer's "one message, N attachments" contract already holds for them, and WeCom ingests no files. Telegram is the only channel needing its envelope restored to that shape, and it must happen before entering the shared pipeline; hoisting it would push a transport quirk into the layer whose whole value is being channel-neutral._dispatchbuffers members keyed by(chat_id, media_group_id). Keying on the id alone would merge two chats' members into one message addressed tohead.chat_id— leaking content across a conversation boundary and swallowing the second chat's copy.message_idso a reply or steer-ack targets the album's first message.close().Channel parity gap restored.
discord/transport_dispatch.pygates steering onand not msg.attachments; Telegram never had it.steerforwards text only, so an attachment message arriving mid-turn was steered caption-only and silently lost every file. Album buffering made this routinely reachable — a follow-up typed during the debounce window starts a turn, so the album's own flush lands mid-turn. Telegram now gates identically, and such a message always takes the queue path, which carries attachments.Known residual — tracked in #2217
An inbound message arriving inside the shutdown window is refused by
SessionManager._closingand lost. This is pre-existing and channel-agnostic: shutdown runs channel teardown andclose_all()concurrently, so a plain single message in that window is refused identically today with no attachments involved. Buffering widens it by at most the debounce interval. The proposed "flush beforeclose_all()" reorder does not fix it —close()never awaits handler tasks, so delivering would require awaiting an unbounded agent turn against the existingtimeout=2.0. The real fix is durable inbound persistence (#2217). Documented in code and pinned by test rather than claimed away.Tests
test/test_telegram_attachments.py(18) — dispatch extraction,_to_attachmentnormalization, ingest end-to-end (image download, document text extraction, video rejection, voice transcription with and without STT), capability flags.test/test_telegram_album.py(11) — 4→1 turn with order and caption preserved; caption recovered from a later member; headmessage_id; non-album message unaffected; interleaved groups separate; same group id in two chats does not merge; buffer fully drained; member cap logged; group cap force-flushes; hard ceiling flushes a never-ending group;close()attempts delivery.test/test_messaging_attachments.py(+7) — the hoistedappend_attachment_context.test/test_telegram.py(+1) — an attachment message is queued, not steered, and the attachments survive the queue (the weaker "steer was skipped" assertion would pass even if the queue dropped them).All guards are mutation-verified: removing each makes a specific named assertion fail. Two tests were tautological on the first pass and were fixed — the
close()test passed with the flush deleted because the fast test window let the natural timer fire, and a contract test asserted on module source where the import line alone satisfied it.