Feishu message flow & execution
How a Feishu message enters Downcity, becomes agent input, and is sent back as a reply
Feishu: end-to-end message -> agent -> reply
This page explains how the current Feishu channel in downcity works internally.
User-facing setup guide: /en/docs/services/chat/feishu
Overview
Startup
When chat service starts the Feishu channel, it:
- loads
appId,appSecret, anddomainfrom the bound channel account - creates:
Lark.ClientLark.WSClient
- registers:
im.message.receive_v1
- starts the long connection
Code:
Inbound parsing
The channel reads these core event fields:
message.chat_idmessage.chat_typemessage.message_idmessage.message_typesender.sender_id.open_id / user_id / union_id
Deduplication
Feishu may redeliver messages, so the runtime uses:
- in-process dedupe
- file-backed dedupe under
.cache/feishu/dedupe
This prevents the same message from being executed more than once.
Current execution input: text plus common attachments
The current Feishu adapter normalizes inbound input by message type:
message_type === text: execute the text body directlymessage_type === post: downgrade rich text into plain text plus downloadable attachmentsmessage_type === image | file | audio | media: download to local cache first, then inject as<file ...>- any other type: send an error message back
So Feishu post, images, videos, files, and audio now follow the same normalized execution flow:
- text / links / mentions inside
postare converted into plain execution text - images / media inside
postare converted into downloadable attachments - all downloadable assets are finally injected as
<file ...> - images and PDFs are also best-effort injected as model
file parts
Automatic ack reaction
In the current release, once an inbound Feishu message:
- passes authorization checks
- and before it enters command handling or agent execution
the bot first adds a lightweight reaction to the triggering user message to signal "received, processing".
The current implementation uses Feishu message reaction type OK.
This reaction is best-effort:
- if it succeeds, the user sees an immediate acknowledgment
- if it fails, command handling and agent execution continue normally
Code:
Sender name and chat title resolution
This is the most important part of the Feishu integration.
The current runtime uses tenant_access_token
The adapter exchanges:
appId + appSecret
for:
tenant_access_token
Then it calls:
contact/v3/users/...im/v1/chats/...im/v1/chats/:chat_id/members
This means:
- runtime display-name resolution depends on tenant scopes
- a successful manual
user_access_tokentest does not prove the bot path will work
Resolution order
Current order:
- extract sender identity from the event
- prefer
open_id - then
user_id - then
union_id
- prefer
- call
contact/v3/users/:id - if no name is returned, try:
im/v1/chats/:chat_id/members
- if still unresolved, fall back to raw ids
Code:
Context routing and persistence
For each inbound message, the runtime creates or updates route metadata such as:
contextIdchannelchatIdtargetTypemessageIdactorIdactorNamechatTitle
These are stored in:
.downcity/channel/meta.json
The internal target key is shaped like:
feishu|<chatId>|<chatType>|<threadId>Code:
How inbound messages enter the agent
The adapter wraps the inbound platform message into a normalized user message:
<info>
channel: feishu
context_id: ...
chat_id: ...
chat_type: p2p
message_id: ...
user_id: ...
username: ...
</info>
user textSo the model sees:
- which channel this came from
- which
contextIdit belongs to - who sent it
- whether this is a private or group chat
Code:
Audit history is also written to:
.downcity/chat/<contextId>/history.jsonl
Reply delivery
There are two outbound paths:
Private chat (p2p)
Uses:
client.im.v1.message.create
and sends directly by chat_id.
Group chat
Uses:
client.im.v1.message.reply
to reply against the original message_id.
Code:
The current version also supports <file> conversion in outbound replies:
<file type="document">reports/downcity-office-hours.md</file>Delivery now follows the actual message structure:
- parse the message into ordered
segments[] - send text segments in source order
- upload/send attachment segments in source order
So if the source message is:
Part one
<file type="document" caption="Reference">reports/a.pdf</file>
Part twoFeishu receives it in the same order:
- text:
Part one - attachment:
reports/a.pdf - text:
Part two
Why manual user_access_token tests can pass while runtime still fails
These are different token modes.
Manual test
Often uses:
user_access_token- user-authorized view
Running channel
Uses:
tenant_access_token- app identity view
So this is possible:
- manual
user_access_tokentest returns the real name - the running Feishu bot still only sees
open_id
There is no contradiction there.
Current limitations
The current Feishu channel still has these limitations:
- complex
poststyling (bold/italic/layout) is currently downgraded to plain text instead of preserving visual style - sender/chat display names depend on tenant scopes and contact visibility
user_access_tokenis not currently used for inbound metadata enrichment
Troubleshooting order
If you see:
chatTitle = nullchatDisplayNameSource = chat_idusername = unknown
check in this order:
- tenant scopes are actually effective
im.message.receive_v1is enabledim:chat.members:readis effective for the appcontact:user.employee_id:readonlyis effective for the app- contact visibility includes the current user
- send a new Feishu message so route metadata refreshes