Skip to content

Case Study: Feishu Bot "Connected Fine, Messages Vanish"

A real debugging post-mortem. The bot showed online, logs looked healthy, yet the user's messages never got a reply. The root cause turned out to be neither the network nor the event subscription — it was a stale identity ID left in the channel's access allowlist after switching Feishu apps.

This case is worth keeping for two reasons: ① a trap that is very easy to hit after switching apps, with deeply misleading symptoms; ② a discipline of never jumping from symptom straight to root cause.

Symptoms

  • The Feishu custom app was switched to a new app (from a task-oriented app to a new IM-capable one).
  • The bot process was healthy: logs showed client ready, event-dispatch is ready, bridge online — the long-connection was established.
  • The user sent "hi" in a direct message and the bot was completely silent — no error, no reply.
  • Repeated restarts and repeated checks of the Feishu console ("permissions, event subscriptions, published version — all configured") changed nothing.

Three Wrong Diagnoses (the detours)

Three wrong conclusions were reached in turn. Each sounded reasonable, yet none was backed by decisive evidence:

  1. "The new app's published version doesn't subscribe to im.message.receive_v1." Based on seeing only task-type events in the log. Plausible — but "I didn't see message events" is not the same as "the subscription is missing."
  2. "Multiple long-connections are competing for events." Repeated restarts did leave orphan connections, and Feishu distributes events across multiple active long-connections of the same app. A real but non-root-cause distraction here.
  3. Suspecting the model layer (empty response). Because this model has a known history of "success signal but no extractable text" through certain gateways, the empty reply was briefly blamed on the model. Again, no evidence.

Lesson: every "root cause" conclusion should be backed by a decisive log line or experiment — not "the symptom looks like it." The first three all jumped from symptom to conclusion, so all three were wrong.

The Decisive Diagnosis

One remark redirected everything: "Forget the container — just test it with the simplest script and you'll know."

So a minimal standalone script was written (a raw SDK long-connection, bypassing the entire product stack) using the new app's credentials, subscribing to im.message.receive_v1 and printing received events. Result:

>>>>> GOT im.message.receive_v1 — text="hi" <<<<<

That single line refuted the first two conclusions: the new app did subscribe to message events, and the long-connection could receive them. The problem had to be inside the product stack.

Scoping back into the container and cleaning down to a single clean connection, the truly decisive evidence appeared:

[feishu:audit] deny from=ou_XXXXXXXX conv=dm — sender not in allowFrom (dm)

The message was received — but denied by the channel's access control.

Root Cause

In Feishu, both open_id (user identity) and chat_id (conversation) are scoped per appthe same person has a completely different open_id under a different app.

After switching apps:

  • the user's open_id under the new app changed;
  • but the channel's access.json still held the old app's IDs in allowFrom / allowChats;
  • so every message hit "sender not in allowlist" → silently denied.

The truth behind "not receiving messages" was "received, but denied by our own allowlist." That [feishu:audit] deny line was the smoking gun — which, unfortunately, nobody looked at early on.

The Fix

Add the user's open_id under the new app to allowFrom, add the DM chat_id to allowChats, and restart the node so the worker reloads access.json:

jsonc
// <node>/channels/feishu/access.json
{
  "allowFrom": [
    "ou_NEW_APP_OPEN_ID"        // open_id under the NEW app (not the old one!)
  ],
  "allowChats": [
    "oc_NEW_APP_CHAT_ID"        // DM / group chat_id under the new app
  ]
}

After the restart, the full round-trip works:

[feishu:bridge] placeholder sent ...      # received + placeholder
[claude] success | ... | out>0            # model produced a reply
[feishu:bridge] reply text sent ...        # reply delivered

Three Things You Must Update When Switching Feishu Apps

This is the core checklist of the case. All three must change; the one most often missed is the third:

#LocationContent
1Process env / deployment configFEISHU_APP_ID / FEISHU_APP_SECRET
2<node>/channels/feishu/.envSame as above (the worker actually reads this file, which can override env vars)
3<node>/channels/feishu/access.jsonallowFrom / allowChats — because open_id / chat_id change with the app, all old IDs become invalid

Classic symptom of missing #3: client ready is fine, events arrive, yet the user's messages get "no reaction."

General Decision Tree (when the bot doesn't receive messages)

Distilled into a tree that narrows through three layers — connection → subscription → access control:

First check whether the worker log has client ready / event-dispatch is ready.

  • A) No client ready, repeated connection failures = the long-connection can't connect = network block (corporate network / DPI blocking the domain). Deploy where the Feishu long-connection domain is reachable; restarts and config changes won't help.

  • B) client ready present, but zero events in the log = connected, but the platform isn't pushing = app-side subscription problem. Most common: wrong event subscribed, missing im.message.receive_v1, subscription mode not set to "long-connection", or changes not "version created → published".

  • C) client ready present, events arrive, but the bot doesn't reply (this case) = message received, denied at the application layer. You must grep the log for deny / allowFrom:

    • [feishu:audit] deny ... not in allowFrom → allowlist problem (most common after switching apps, see above).
    • empty vendor result → the model generated output but no text could be extracted (usually a model × gateway response-format mismatch); switch to a compatible model.
    • No conversation found → session state invalidated; clear the node's session field and restart.

Debugging Discipline Worth Keeping

  1. Don't jump from symptom to root cause. Every "root cause" conclusion needs a decisive log line or a minimal experiment behind it.
  2. Minimal reproduction first. A standalone script that bypasses the whole product stack often splits the problem in half instantly (platform/network vs. your own code).
  3. Reduce the environment to a single variable first. Multiple orphan connections and leftover configs make symptoms drift and mislead diagnosis.
  4. Proactively grep for "denied by ourselves" logs like deny. "No reaction" can mean "not received" or "received but rejected" — two completely different directions.

Companion Case: The Bot Can't See Images

A few days later, a trap with similar symptoms and the same disciplines — worth keeping alongside.

Symptom: send the bot an image and it replies "[agent-node] received the event but no processable text/image content". Text messages work fine; only images fail.

Three wrong diagnoses again (same bad habit): first "the user is forwarding a card, not a direct image", then "the app lacks image-download permission", then "the model doesn't support vision" — all guesses from symptoms, no evidence.

The decisive diagnosis:

  1. A sole-connection probe captured the raw event: msg_type=image with a valid image_keyit's a direct image, refuting the first two guesses.
  2. The key move: the worker's download function was catch { return null }, swallowing every download error, so the log showed nothing. After injecting one error-log line into that catch and resending, it surfaced instantly:
[feishu:image] … downloadImage threw: X.on is not a function

Root cause: the Feishu SDK (lark node-sdk v1.68) im.messageResource.get() returns a wrapper object (with .getReadableStream() / .writeFile() methods), not a raw stream you can .on("data") directly. The old worker called .on() on the wrapper → threw → swallowed by the catch → no image bytes → treated as "no content".

The fix: const stream = resp.getReadableStream() then stream.on("data", …). A one-line difference — and exactly what the official PR #324 (fix(#179 image): downloadImage SDK misuse) already fixed. The affected container was running a build from before #324 (e.g. 2.2.22-preview.2); upgrading to a preview with the fix resolves it. After the fix, verified end-to-end: image written to disk → multimodal: 1/1 image(s) attached → model vision success (a 2000+ token reply describing the image) → reply delivered.

Two disciplines this case adds:

  1. A silent catch { return null } is a prime-suspect anti-pattern. It hides the real error and leaves you staring at "no reaction". For this class of bug, step one is to add a log line to the catch and let the error speak — here, one resend after that pinpointed it.
  2. When behavior doesn't match the latest code, suspect version drift first. The same bug may already be fixed upstream while your deployment runs an old build. Compare cat node_modules/<pkg>/package.json version against npm view <pkg>@preview version — often a one-second giveaway.

Image-recognition checklist: ① a vision-capable model (MiniMax-M3 / Claude Sonnet / …); ② the node's flags.modelImageCapable=true; ③ an agent-network version that includes the #324 download fix (use a recent enough preview). With all three, images are recognized on send.

Powered by Sleep2AGI