Feishu
Connect Downcity to Feishu/Lark private chats and group chats
Feishu integration
Use Feishu (or Lark) to connect Downcity to:
- direct messages
- group chats
- route binding and context persistence
- agent reply delivery back to Feishu
- file attachment delivery via
<file type="document">path</file> - inbound image, video, file, and audio download with
<file ...>injection into Agent execution - best-effort model
file partsinjection for inbound images and PDFs
Want the internal execution flow? See: /en/docs/services/chat/feishu-message-flow
Short version
The current Feishu channel in downcity uses:
- a self-built Feishu app
- bot + WebSocket long connection
- server-side
tenant_access_token
This is the most important thing to keep in mind:
- if your manual API test works with a
user_access_token, that does not mean the running bot can resolve the same user name - the running bot depends on tenant scopes, effective authorization, and contact visibility
Setup
1) Create the app
Create a self-built app in Feishu Open Platform and enable:
- bot capability
- message receiving
- WebSocket / long connection mode
The current runtime works best with long connection mode and does not require a public webhook URL first.
2) Subscribe to events
At minimum, enable:
im.message.receive_v1
Without this event the bot will start, but it will not receive chat messages.
3) Create a channel account
In Console UI, open Global / Channel Accounts and create a Feishu account such as feishu-main:
channel = feishu- fill
appId - fill
appSecret - for Lark, optionally set:
domain = https://open.larksuite.com
4) Bind it in downcity.json
{
"services": {
"chat": {
"channels": {
"feishu": {
"enabled": true,
"channelAccountId": "feishu-main"
}
}
}
}
}5) Required permissions
Meaning:
role: a permission group such asdefault,member, oradminpermission: permissions owned by that group, such aschat.dm.use,chat.group.use, oragent.manageuser -> role: which role a Feishu user belongs to
The same config is now stored in the chat_auth_* structured tables in console ~/.downcity/downcity.db and managed through city chat or city chat auth set feishu:<userId>, instead of being written to project downcity.json in plaintext. New users fall back to default roles, so no pairing flow is required; group/channel usage depends only on the speaking user's role.
For the current runtime, tenant app scopes matter most.
Recommended minimum tenant scopes:
im:message:readonlyim:message:send_as_botim:chat:readim:chat:readonlyim:chat.members:readcontact:user.basic_profile:readonlycontact:user.employee_id:readonly
In practice you will often also keep:
im:chatim:chat.members:bot_accesscontact:user.base:readonlycontact:user.id:readonly
6) Publish and re-authorize
Checking a scope in the console is not enough.
Make sure:
- the app version is published
- tenant admin authorization is refreshed
- the scopes are really enabled for the tenant side, not only user-side OAuth
- contact visibility includes the user you are testing with
Start runtime
city agent startExpected logs:
Feishu channel enabled🤖 Starting Feishu Bot...Feishu Bot started, using long connection mode
How to verify
In Console UI
Check:
- Feishu channel is bound to the expected channel account
- status shows
link connected
Send a new Feishu message
Route metadata is refreshed on new incoming messages.
If you changed permissions but did not send a new message, old metadata will stay old.
Inspect route metadata
If name resolution works, the Feishu route in .downcity/channel/meta.json should contain:
actorNamechatTitle
If it only contains:
actorIdchatId
then the backend still did not resolve a display name.
Important behavior
Current Feishu channel behavior:
- inbound transport is WebSocket
- execution accepts text + rich
post+ image + video + file + audio inputs - text / links / mentions inside
postare normalized into plain text, whilepostimages / media are downloaded into.downcity/.cache/feishu/and injected as<file ...> - inbound image, video, file, and audio messages are downloaded into
.downcity/.cache/feishu/and injected as<file ...> - inbound images and PDFs are also best-effort injected as model
file partsfor multimodal models - when a user replies to a Feishu message, runtime will best-effort fetch the parent message body from
parent_idand inject it into the Agent input - outbound replies support
<file ...>conversion and are delivered segment-by-segment in the actual text/attachment order from the source message - normal AI text replies are sent as plain messages by default and do not auto-reply to the latest message
- a Feishu reply link is used only when reply mode is explicitly requested for a target
message_id - sender name and chat title are resolved on a best-effort basis
- if resolution fails, processing still continues with raw ids
So the channel may still work while the UI only shows chat_id / open_id.
If a reply is structured as text -> attachment -> text, outbound delivery keeps that same order instead of forcing “text first, attachments later”.
tenant_access_token vs user_access_token
This is the most common source of confusion.
The running channel uses tenant_access_token
The runtime uses:
appId + appSecrettenant_access_token
and then calls user/chat metadata APIs with that token.
So what matters is:
- tenant scopes
- contact visibility
Why manual user_access_token tests may still succeed
A user_access_token represents a specific authorized user.
That can return richer user-visible data, but the running bot does not currently use a user token for inbound chat metadata enrichment.
So this is completely possible:
- your manual
user_access_tokentest returns a real name - the running Feishu bot still shows
unknown
Common issues
Messages arrive, but title / username is missing
Check:
- tenant scopes are really effective
- app version was published
- admin re-authorized the app
- contact visibility includes the user
- you sent a new Feishu message after changing permissions
I enabled user scopes, why does the bot still fail?
Because the runtime uses a tenant token, not your manual user OAuth token.
Group title or private chat name is still missing
Usually this means one of these is still not effective:
im:chat.members:readcontact:user.employee_id:readonly- contact field visibility for the current app