Bug: Feishu DM — MEDIA: attachment silently fails; outbound text path also drops streaming card
Summary
In a Feishu DM (p2p) session, when the assistant replies with a MEDIA:<local-path> directive pointing at a PNG inside an allowed local-media root, the user never sees the image. The OpenClaw log shows feishu[default] Discarded streaming card: cardId=<id> followed by dispatch complete (queuedFinal=true, replies=1), but the final card delivered to the user contains no image and no explanatory error. The same happens with the read tool — the image is down-scaled to 1200px JPEG and injected into the model context, but no outbound attachment is produced.
Expected
- The Feishu DM receives an
image message with the uploaded image_key content.
Actual
- The final card arrives in the user DM with only the bare local path echoed as text (e.g.
📎 /tmp/.../foo.png), not the image. Sometimes the entire reply is dropped (dispatch complete (replies=0)).
- The
console.error("[feishu] sendMediaFeishu failed: ...") in channel.runtime-CSBUy56o.js:539 does not appear in the gateway log, so the catch-block fallback to sendOutboundText isn't visibly firing — the failure is being swallowed even further upstream.
Reproduction
- Configure an OpenClaw agent with a Feishu DM channel (websocket mode).
- In the agent session, take any small PNG/JPG in
/tmp/openclaw/... (a default preferredTmpDir local-media root).
- In the assistant reply, include a single line
MEDIA:/tmp/openclaw/.../test.png (with or without surrounding prose).
- Send the message. Observe the Feishu DM on the user side: no image arrives.
- In the gateway log:
feishu[default] Started streaming: cardId=<id>, messageId=om_xxx
feishu[default] Discarded streaming card: cardId=<id>
feishu[default]: dispatch complete (queuedFinal=true, replies=1) (or replies=0 on some attempts)
- No
[feishu] sendMediaFeishu failed line.
- Calling the same
im/v1/images + im/v1/messages endpoints directly from a standalone Node script with the same tenant_access_token (scopes: im:message) succeeds end-to-end. So the OpenClaw→Feishu path is the broken one, not the bot credentials or scopes.
Where the code path runs
payloads-DwYjapHo.js:341 — splitMediaFromOutput strips MEDIA: and pushes the path into a media[] array (the directive itself parses fine; isValidMedia returns true for absolute local paths inside preferredTmpDir).
web-media-BupJTPdR.js — assertLocalMediaAllowed uses fs.realpath on both the file and each allowed root, then isPathInside. On macOS the realpath-vs-resolve comparison still works because isPathInside ultimately compares path.relative of the realpathed file vs each realpathed root, and local-roots-Cc0PVjtu.js lists /tmp/openclaw/ (or its realpath) as a default root. So the allowlist check is not the blocker.
send-CVLKZYre.js:240 — uploadImageFeishu calls client.im.image.create({ data: { image_type: 'message', image: <buffer> } }). The same call works from a manual script with the same SDK client.
send-CVLKZYre.js:500 — sendMediaFeishu loads the file via loadWebMedia, uploads, then calls im.message.create. The dispatch entry is in channel.runtime-CSBUy56o.js:493 (the sendMedia adapter).
channel.runtime-CSBUy56o.js:539 — the catch block that would log [feishu] sendMediaFeishu failed: and fall back to sendOutboundText with 📎 <path> does not log in the failing cases I observed, so the failure is swallowed before this catch (possibly inside the streaming-card discard, or by the requestFeishuApi retry path).
monitor.account-CHMx6Lne.js:1274 — Discarded streaming card is the normal flow-control step (delete the streaming card and replace with a final card). When the image is part of that streaming card, the image resource vanishes along with the deleted message, leaving the replacement card empty.
Additional context
- Image is < 100KB, well within Feishu limits (10MB, 12000x12000).
appId, appSecret, tenant_access_token, and the user open-id all work fine when called directly with urllib/https from a script (the image uploads to im/v1/images and is delivered as msg_type: image).
- The same issue happens with the
read tool's image output — confirmed it lands in agents/tool-images (down-scaled, outputMimeType image/jpeg, sourceWidth/sourceHeight logged) but never becomes an outbound attachment.
tools.profile (coding) strips the message tool, so we cannot call sendMediaFeishu directly from the assistant. The only OpenClaw-supported outbound path is the MEDIA: directive, which is what's broken.
Workaround
Bypass the OpenClaw feishu plugin entirely: call tenant_access_token → im/v1/images → im/v1/messages?receive_id_type=open_id directly. This works reliably. (A standalone Node script that does this is in our private tree; the script is the workaround, not a fix.)
Suggested fix directions (no claim, just observations)
- Investigate why the streaming-card discard in
monitor.account-CHMx6Lne.js doesn't preserve attached image resources, or why image upload errors during a streaming card aren't surfaced.
- Make
console.error in the sendMedia catch actually emit when the failure happens (it doesn't show up in the gateway log, which is the main reason this was so hard to debug).
- The streaming card's
discard() deletes the original message; if the final replacement message has no media fields set, the user sees an empty card with no error.
Bug: Feishu DM —
MEDIA:attachment silently fails; outbound text path also drops streaming cardSummary
In a Feishu DM (p2p) session, when the assistant replies with a
MEDIA:<local-path>directive pointing at a PNG inside an allowed local-media root, the user never sees the image. The OpenClaw log showsfeishu[default] Discarded streaming card: cardId=<id>followed bydispatch complete (queuedFinal=true, replies=1), but the final card delivered to the user contains no image and no explanatory error. The same happens with thereadtool — the image is down-scaled to 1200px JPEG and injected into the model context, but no outbound attachment is produced.Expected
imagemessage with the uploadedimage_keycontent.Actual
📎 /tmp/.../foo.png), not the image. Sometimes the entire reply is dropped (dispatch complete (replies=0)).console.error("[feishu] sendMediaFeishu failed: ...")inchannel.runtime-CSBUy56o.js:539does not appear in the gateway log, so the catch-block fallback tosendOutboundTextisn't visibly firing — the failure is being swallowed even further upstream.Reproduction
/tmp/openclaw/...(a defaultpreferredTmpDirlocal-media root).MEDIA:/tmp/openclaw/.../test.png(with or without surrounding prose).feishu[default] Started streaming: cardId=<id>, messageId=om_xxxfeishu[default] Discarded streaming card: cardId=<id>feishu[default]: dispatch complete (queuedFinal=true, replies=1)(orreplies=0on some attempts)[feishu] sendMediaFeishu failedline.im/v1/images+im/v1/messagesendpoints directly from a standalone Node script with the sametenant_access_token(scopes:im:message) succeeds end-to-end. So the OpenClaw→Feishu path is the broken one, not the bot credentials or scopes.Where the code path runs
payloads-DwYjapHo.js:341—splitMediaFromOutputstripsMEDIA:and pushes the path into amedia[]array (the directive itself parses fine;isValidMediareturnstruefor absolute local paths insidepreferredTmpDir).web-media-BupJTPdR.js—assertLocalMediaAllowedusesfs.realpathon both the file and each allowed root, thenisPathInside. On macOS the realpath-vs-resolve comparison still works becauseisPathInsideultimately comparespath.relativeof the realpathed file vs each realpathed root, andlocal-roots-Cc0PVjtu.jslists/tmp/openclaw/(or its realpath) as a default root. So the allowlist check is not the blocker.send-CVLKZYre.js:240—uploadImageFeishucallsclient.im.image.create({ data: { image_type: 'message', image: <buffer> } }). The same call works from a manual script with the same SDK client.send-CVLKZYre.js:500—sendMediaFeishuloads the file vialoadWebMedia, uploads, then callsim.message.create. The dispatch entry is inchannel.runtime-CSBUy56o.js:493(thesendMediaadapter).channel.runtime-CSBUy56o.js:539— the catch block that would log[feishu] sendMediaFeishu failed:and fall back tosendOutboundTextwith📎 <path>does not log in the failing cases I observed, so the failure is swallowed before this catch (possibly inside the streaming-card discard, or by therequestFeishuApiretry path).monitor.account-CHMx6Lne.js:1274—Discarded streaming cardis the normal flow-control step (delete the streaming card and replace with a final card). When the image is part of that streaming card, the image resource vanishes along with the deleted message, leaving the replacement card empty.Additional context
appId,appSecret,tenant_access_token, and the user open-id all work fine when called directly withurllib/httpsfrom a script (the image uploads toim/v1/imagesand is delivered asmsg_type: image).readtool's image output — confirmed it lands inagents/tool-images(down-scaled, outputMimeTypeimage/jpeg, sourceWidth/sourceHeight logged) but never becomes an outbound attachment.tools.profile (coding)strips themessagetool, so we cannot callsendMediaFeishudirectly from the assistant. The only OpenClaw-supported outbound path is theMEDIA:directive, which is what's broken.Workaround
Bypass the OpenClaw feishu plugin entirely: call
tenant_access_token→im/v1/images→im/v1/messages?receive_id_type=open_iddirectly. This works reliably. (A standalone Node script that does this is in our private tree; the script is the workaround, not a fix.)Suggested fix directions (no claim, just observations)
monitor.account-CHMx6Lne.jsdoesn't preserve attached image resources, or why image upload errors during a streaming card aren't surfaced.console.errorin thesendMediacatch actually emit when the failure happens (it doesn't show up in the gateway log, which is the main reason this was so hard to debug).discard()deletes the original message; if the final replacement message has no media fields set, the user sees an empty card with no error.