feat: receive images, voice and files on the Weixin channel - #2444
Conversation
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've inspected the full diff and the surrounding contracts (shared attachment pipeline, command parsing, the double busy-check flow, CDN download/decrypt, authorization ordering). Let me verify one concern: the AES decrypt runs synchronously in The download only happens post-authorization (via dispatch), the CDN host is fixed (no SSRF), the double No findings. [OPUS-REVIEWED] 8ab731a Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSAdvisory design-level review of Design-Verdict: PASS Root cause fixed at the right layers — protocol in Suggestions
[DESIGN-REVIEWED] 8ab731a |
fd0c2f8 to
30b9e61
Compare
|
Round 1 dispositions — pushed as GPT + Opus (blocking) — synchronous write of up to 32 MB on the event loop. Fixed. GPT (blocking) + Design Review (Watch) — mid-turn attachments become dangling paths. Fixed, and both of you were right about the mechanism I got wrong: Design Review (Suggestion) — all-or-nothing voice short-circuit. Fixed. Two CI reds fixed alongside:
The Still unverified end to end: the real CDN round trip. The live gateway holds the only iLink long-poll for this bot account, so the first real image after cutover remains the outstanding check. |
30b9e61 to
d1cf728
Compare
|
Round 2 dispositions — pushed as GPT (blocking) — session can become busy during ingestion. Fixed, and a real hole I left open: the pre-ingest check only covers a turn that was already live. A CDN download takes real time, so a turn can start while it is in flight, and the already-downloaded path would then be inlined into a steer whose file this frame deletes — the exact failure the first check exists to prevent.
CodeQL The inline Substance of the dismissal: ECB is dictated by the remote protocol. Inbound iLink media arrives already AES-128-ECB-encrypted under a per-file random key and the CDN accepts nothing else, so there is no algorithm choice to make. It is confined to Coverage Gate / PR Readiness were downstream of the reds above and need no separate change. |
d1cf728 to
bb866cf
Compare
|
Round 3 disposition — pushed as GPT (blocking) — command-like captions silently discard attachments. Real, and the same silent-drop class this PR exists to remove, so it is fixed. I did not take the literal suggested fix ("move attachment ingestion before command parsing"), because on that path it makes things worse rather than better: What the finding actually identifies is the silence, not the skipped download. So the command intercept now names it: when a command message carries media, the user is told Two tests: If you would rather an image captioned CodeQL is green at this SHA — the two ECB alerts (#595, #596) are dismissed with reasons recorded, per round 2. 120 local tests green; isort/flake8 clean; mypy clean on the changed file (its 2 errors are pre-existing in |
|
Round 4 — no code change; the last remaining red is not this PR's.
Evidence it is inherited rather than mine:
Filed as #2461 with the root-cause leads (the test injects the I/O error by patching I re-ran the failed shard on this PR in case it is intermittent rather than deterministic; if it comes back red, #2461 is the owner. Everything else at |
|
Ready for approve at 47 checks pass. All four review bots PASS with zero open findings: GPT 5.6 ✅, Opus 4.8 ✅, Design Review (Fable 5) ✅, UX Review ✅. CodeQL green. MERGEABLE. The one remaining red is Three review rounds, all findings accepted as real, none rebutted:
Plus two CI reds fixed: Eight mutations verified — each one breaks production code and fails its named test. Still unverified end to end: the real CDN round trip. The live gateway holds the only iLink long-poll for this bot account, so a second client would steal it. The first real image after cutover is the outstanding check. |
Inbound media on iLink arrived as a CDN reference, and the transport read only the ITEM_TEXT item — so a message carrying just a screenshot hit `if not text: return` and was discarded with no reply and no log line. The sender saw a successful send; the agent was never told anything arrived. weixin/media.py owns the protocol-shaped half: percent-encoded CDN download URLs, the dual aes_key encoding (base64 of raw 16 bytes for images, base64 of ascii hex for file/voice/video, discriminated by decoded length plus a strict hex check), AES-128-ECB decrypt with PKCS7 unpadding, and a size cap enforced on bytes actually read rather than on Content-Length. ECB is dictated by the remote protocol, never chosen for data of our own, so the two call sites carry an inline CodeQL suppression next to the existing bandit one. cryptography is declared in install_requires for the same reason qrcode already is: a shipped channel must not depend on another feature's dependency tree to read a message. weixin/attachments.py maps the four CDN-backed item types onto the shared Attachment and delegates to messaging/attachments.py, so classification, limits, image-signature validation, redaction, rejection wording, transcription and temp-file ownership stay channel-neutral and identical to Telegram/Discord. A voice item that already carries server-side text short-circuits the download, per item — iLink voice is SILK, which no shipped transcription backend decodes, but a transcript on one voice note says nothing about the next one. The decrypted payload is written through asyncio.to_thread: an object can be 32 MB and TMPDIR is not guaranteed local, so an inline write would stall the single gateway event loop and the liveness heartbeat along with it. Ingestion is skipped while a turn is already live. steer() sends raw text and returns before the running turn consumes it, so this frame's cleanup would delete the temp file first and hand the model a path to a file that no longer exists; only the fresh-turn path inlines images as blocks. A mid-turn sender is told to resend the attachment after the reply, and any caption beside it still reaches the turn through steer. files_inbound flips to True; files_outbound stays False because the upload half (getuploadurl + encrypted CDN PUT) is unimplemented and the capability contract must not over-promise.
bb866cf to
8ab731a
Compare
|
Correction to my round-4 comment, and a rebase: I called the
Rebased onto current main ( Re-verified locally after the rebase: 204 tests pass across the Weixin, chat-pins, and pip-deps suites; isort and flake8 clean. The commit content is unchanged from |
|
Fully green at 49/49 checks pass, zero reds, The Summary of what landed, for the reviewer:
Plus Eight mutations verified — each breaks production code and fails its named test. Outstanding, and not something CI can cover: the real CDN round trip is unexercised. The live gateway holds the only iLink long-poll for this bot account, so a second client would steal it. The first real image after cutover is the remaining check. |
…ev#2444) Inbound media on iLink arrived as a CDN reference, and the transport read only the ITEM_TEXT item — so a message carrying just a screenshot hit `if not text: return` and was discarded with no reply and no log line. The sender saw a successful send; the agent was never told anything arrived. weixin/media.py owns the protocol-shaped half: percent-encoded CDN download URLs, the dual aes_key encoding (base64 of raw 16 bytes for images, base64 of ascii hex for file/voice/video, discriminated by decoded length plus a strict hex check), AES-128-ECB decrypt with PKCS7 unpadding, and a size cap enforced on bytes actually read rather than on Content-Length. ECB is dictated by the remote protocol, never chosen for data of our own, so the two call sites carry an inline CodeQL suppression next to the existing bandit one. cryptography is declared in install_requires for the same reason qrcode already is: a shipped channel must not depend on another feature's dependency tree to read a message. weixin/attachments.py maps the four CDN-backed item types onto the shared Attachment and delegates to messaging/attachments.py, so classification, limits, image-signature validation, redaction, rejection wording, transcription and temp-file ownership stay channel-neutral and identical to Telegram/Discord. A voice item that already carries server-side text short-circuits the download, per item — iLink voice is SILK, which no shipped transcription backend decodes, but a transcript on one voice note says nothing about the next one. The decrypted payload is written through asyncio.to_thread: an object can be 32 MB and TMPDIR is not guaranteed local, so an inline write would stall the single gateway event loop and the liveness heartbeat along with it. Ingestion is skipped while a turn is already live. steer() sends raw text and returns before the running turn consumes it, so this frame's cleanup would delete the temp file first and hand the model a path to a file that no longer exists; only the fresh-turn path inlines images as blocks. A mid-turn sender is told to resend the attachment after the reply, and any caption beside it still reaches the turn through steer. files_inbound flips to True; files_outbound stays False because the upload half (getuploadurl + encrypted CDN PUT) is unimplemented and the capability contract must not over-promise.
1. What is the problem?
Sending an image to the bot on WeChat did nothing. No reply, no error, no log
line — the message simply disappeared.
WeixinTransport.receiveextracted only theITEM_TEXTitem from an inboundiLink envelope and then dropped anything with no text:
An uncaptioned screenshot has no
ITEM_TEXTitem, so it hit thatreturnbefore authorization, before dispatch, before any logging.
2. Why this issue matters to the user
The failure is silent on the side that matters. WeChat shows the image as sent,
the bot keeps its typing indicator, and the user waits for a reply to a message
the agent was never told about. Nothing distinguishes it from a slow turn.
It also removes the fastest way to give an agent context. This PR exists because
I hit it live: I needed to show the agent a screenshot of a WeChat chat, could
not, and had to leave WeChat and re-send it from the dashboard. On a phone that
is the difference between one gesture and abandoning the channel.
Voice notes and file attachments were dropped by the same branch.
3. How our fix solves it
Symptom — an image sent on WeChat is never answered.
Direct cause —
if not text: returntreats an empty text extraction as anempty message.
Why the extraction is empty — iLink envelopes never carry media bytes. An
image arrives as a
CDNMediareference (encrypt_query_param+aes_key)pointing at an AES-128-ECB-encrypted object on the WeChat CDN, in an
image_item, not atext_item.Root cause — the transport had no CDN path at all, so the only item type it
could read was text, and emptiness was overloaded to mean "nothing arrived".
The fix is three separated layers:
weixin/media.py— the protocol-shaped half, and nothing else: CDNdownload-URL construction with the query param percent-encoded (it is base64,
so a raw
+reads back as a space and a raw/as a path separator), keydecoding, AES-128-ECB decrypt with PKCS7 unpadding, and a size cap enforced on
bytes actually read rather than on
Content-Length. ECB is dictated by theremote protocol, not chosen here; it is confined to this module and used only
to read bytes WeChat already encrypted.
aes_keycarries two encodings for the same value —base64(raw 16 bytes)for images,base64(ascii hex)for file/voice/video — and iLink neversays which. Discrimination is by decoded length plus a strict hex check,
because guessing wrong produces plausible garbage rather than an error.
weixin/attachments.py— maps the four CDN-backed item types onto theshared
Attachmentand delegates tomessaging/attachments.py. Everythingpolicy-shaped (classification, per-class size limits, image-signature
validation, redaction, rejection wording, transcription, temp-file ownership)
stays channel-neutral and behaves exactly as it does for Telegram and Discord.
iLink labels no image format at all, so images are declared
image/jpegandthe shared layer re-sniffs by magic bytes, rejects non-images (CWE-434), and
renames the temp file to what it actually is.
A voice item that already carries server-side
textshort-circuits thedownload entirely: iLink voice is SILK, which no shipped transcription backend
decodes, so fetching it would spend a CDN round trip to produce
"transcription failed" when the server already handed over a transcript.
weixin/transport.py— collects media items intoInboundMessage.attachmentsand only drops the envelope when there is neither text nor media.
files_inboundflips toTrue;files_outboundstaysFalsebecause theupload half (
getuploadurl+ encrypted CDN PUT) is unimplemented and thecapability contract must not over-promise.
Ingestion is skipped while a turn is already live. Only the fresh-turn
session/promptpath inlines an image path as an image block;steer()sendsraw text and is fire-and-forget, so
_handle_busyreturns immediately and thefinallyintransport_dispatch.pydeletes the temp file before the in-flightturn has read the steer — the model would receive a path to a file that no
longer exists. So a mid-turn sender is told to resend the attachment once the
reply finishes, and any caption beside it still reaches the running turn through
steer. An ingestion failure appends a visible
[Attachment could not be read]rather than reintroducing silence.
is_busyis checked twice, in_ingest_or_refuse: once before downloadinganything, and once after the download returns, because a CDN fetch takes real
time and a turn can start while it is in flight. On the second check the temp
files are discarded immediately rather than at the end of the frame, and only the
original caption carries on. There is no suspension point between that check and
_drive's own one, so the two cannot disagree — the window is closed, notnarrowed. On the path that does ingest, temp files are cleaned up in a
finally.The decrypted payload is written through
asyncio.to_thread: a CDN object canbe 32 MB and
TMPDIRis not guaranteed to be local disk, so writing it inlinewould stall the single gateway event loop — and the liveness heartbeat with it —
for the duration of the write. Mirrors the Telegram and Discord callbacks.
cryptographyis declared ininstall_requiresrather than an extra, for thesame reason
qrcode[pil]already is: the Weixin channel ships enabled-by-configand must not depend on another feature's dependency tree to read a message. The
two ECB call sites carry an inline CodeQL suppression next to the existing
bandit one — ECB is what the WeChat CDN already encrypted with, and we never
encrypt our own data with it, so there is no algorithm choice to make.
4. What tests we did
37 new tests in
test/test_weixin_media.pyand 5 intest/test_weixin_dispatch.py, all green, plus 208 green across the Weixin,shared-messaging and attachment suites (the 2 local reds are
test_weixin_qr.py, which needsqrcodethat this host lacks — green in CI).Eight mutations verified — each breaks production code and the named test fails:
if not text: returntest_media_only_message_is_dispatched_not_droppedtest_thirty_two_non_hex_bytes_are_rejected_not_truncatedtest_exact_block_multiple_plaintext_survives_pkcs7test_base64_query_param_is_percent_encodedasyncio.to_threadtest_the_decrypted_write_never_runs_on_the_event_loop_threadtest_a_transcribed_voice_does_not_suppress_an_untranscribed_siblingtest_a_mid_turn_attachment_is_refused_instead_of_ingestedtest_a_turn_starting_during_the_download_still_refuses_the_attachmentThe offload test asserts by thread identity, not by naming
asyncio.to_thread: an inlineopen()/write()runs on the loop thread, which isthe actual failure, and this catches it however it is spelled.
Gates: pytest, isort, flake8 clean. mypy clean on
src/kiro_crew/weixin/— thetwo remaining errors are in
src/kiro_crew/transcribe.pyand reproduceidentically on
mainat the same count. No frontend changes, so tsc/vitest areuntouched.
test_declared_capabilities_do_not_promise_files_without_a_media_pathwasrewritten rather than deleted: its invariant (a
filesflag must track a realcode path) still holds and is now asserted per direction.
Not yet verified end to end. The real CDN round trip is unexercised, because
the live gateway holds the only iLink long-poll for this bot account and a
second client would steal it. First real image after cutover is the remaining
check.
5. Any other suggestions on the work
Two findings from the investigation that led here, neither addressed by this PR:
Streaming is not available on iLink, and neither is tool-call progress. The
protocol exposes five endpoints (
getupdates,sendmessage,getuploadurl,getconfig,sendtyping) with no edit/update primitive, and upstream@tencent-weixin/openclaw-weixinhardcodesMessageState.FINISHon all threesend paths, so
GENERATINGis inbound-only.MessageItemTypealso carriesundocumented
TOOL_CALL_START: 11/TOOL_CALL_RESULT: 12, which upstream 2.4.4sends by default — I probed both against a live bot: HTTP 200, empty body, no
message_id, and nothing rendered in the client. Declaringchannel_version 2.4.6changed nothing. Worth an issue recording the dead end so the next persondoes not repeat it; the typing indicator remains the only progress affordance.
Outbound media is the natural follow-up. Everything needed is already here
(CDN helpers, AES key handling); it needs
getuploadurl, the encrypted PUT, andfiles_outbound=True. Sending screenshots to the user would close the loop.