Repository navigation
Feishu connector, phase 2: a message in Feishu starts a turn here (inbound over the long connection) #1266
Description
Written-by: Chief (lead decision, 2026-10-26)
Phase 1 (#1263) only posts out. The owner said on 2026-10-26: "第二期也要的" — a message in Feishu must start a turn here. This is phase 2.
Owner: Dev(实现 Lead 2026-10-26 改派:Dev3 已承担 #1271 §1/§2/§4,本期最大一件交 Dev;Triage 原指派 Dev3)
The blocker, measured (not assumed)
The instance's public host answers 401 unauthorized for every path without the platform's own session, so Feishu's event callback cannot reach DSH at all. Measured 2026-10-26 08:36Z against https://u-682b8a4383474b7a9b26433e5b66a8bc.botawaken.com:
POST /api/botwith{"endpoint":"state"}→401, bodyunauthorized,server: cloudflare;GET /api/nonexistent-xyz→401, byte-identical answer.
Same answer for a real route and a made-up one: the gate is in front of the whole instance, not in front of /api/bot. So a callback URL is not a code change here — it is a platform allowlist that nobody on this team can grant.
Inside the instance it would not pass either: core/src/host/channel.js:283-295 resolves the actor only from the x-flybot-member-session header (:715) and then calls rt.authorize(actor, endpoint, body); core/src/host/authz.js:398-404 denies anything not in its table. There is no unauthenticated endpoint to add a callback to, by design.
The way in: the long connection
The official SDK publishes a WebSocket full-duplex client for exactly this — larksuite/node-sdk README: "Developers establish a WebSocket full-duplex channel with the open platform… When an event callback occurs, the open platform will send messages to the developer through the channel" (Lark.WSClient, README around :560). The instance dials out to Feishu, so:
- no public callback URL, no reverse-proxy change, no platform allowlist;
- no
challengehandshake and noX-Lark-Signatureverification, because nothing arrives unauthenticated; - works behind the 401 gate and behind any NAT.
This supersedes the webhook+signature plan written in the first draft of this issue. A callback is only worth revisiting if the platform ever exposes a path, and even then the WS path stays the default for self-hosted instances.
Dependency question for the owner of this issue: @larksuiteoapi/node-sdk is a runtime dependency of core/ (or a hand-written WS client, which I do not recommend). It adds no tool schemas, so it does not contradict the 2026-10-26 decision to keep @larksuiteoapi/lark-mcp unmounted (that one was refused for its ~dozens of tool definitions, #796). Check its licence and Node floor before adding it; if the answer is no, say so in a comment rather than writing a socket by hand.
Where an inbound message lands
Reuse the seam that already exists for a friend's Bot, do not invent a second one. core/src/host/remote-chat.js:286-309: deliver({ handle, text, name, … }) opens (or finds) a chat for that handle, fences the text, and hands it to the chat's route as a user message, then ctx.sessions.flush(...). That is precisely "an outside person typed this into a chat with a Bot here".
So: one Feishu conversation ↔ one chat, handle feishu:<chat_id>, the counter-party named from the Feishu profile, and a sender that posts replies back through phase 1's sender. Not a room, not a group: a 1:1 chat is the smallest thing that can be wrong, and a room can be built later on the same seam.
core/src/host/room-participant-external.js is not this: it supervises a child process over stdio (:1-10), it does not accept an inbound message.
What phase 2 must get right
- Dedupe on
event_id. The long connection reconnects and replays; a replayed event must not start a second turn. Keep a bounded ring of seen ids in state (no unbounded growth, no secret). - One identity, and only the owner's. Feishu
open_id→ the instance's owner. A message from any otheropen_idis refused with one log line and no turn — this is still a single-human product, and a stranger's message must never become a turn that spends the owner's tokens. - Reconnect without double-speaking. After a dropped socket, the client reconnects; the same
event_idarriving twice must land once, and a reply must not be sent twice. - Bounded. One send per inbound message, 15 s timeout as in phase 1 (
CHECK_TIMEOUT_MS), no retry on 4xx. A refused send leaves the chat line marked as not delivered rather than silently pretending. - Status is visible. Phase 1's
connectorsView()row (core/src/host/connectors.js:111-117) grows the inbound state:off | connecting | ready | error, last event at, last error — with the same redaction (reason(),:58-61), so an app secret never reaches the view.
Config and secrets
- Secrets:
FEISHU_APP_ID,FEISHU_APP_SECRET(the app credentials phase 1 already needs for the app path). Never config, never state, never a log line. - Config:
rt.config.feishu.enabled— off by default, because an inbound socket is listening for traffic and that must be a decision, not an upgrade side effect. - State: connection state and the seen-
event_idring only.
Out of scope
- No rooms, no cards, no file upload/download, no per-Bot Feishu apps, no forwarding of Feishu traffic into every group.
- No public callback URL (see the blocker above).
- Do not mount
@larksuiteoapi/lark-mcp.
Red without the change
- Feed one event with
event_id: e1twice → exactly one turn, and the second one logsduplicate; remove the ring and the test sees two turns. - An event whose
open_idis not the owner's → zero turns, one warn; remove the check and the test sees a turn. - Drop the socket between two events → both events still land once each (a reconnect test with a fake WS).
- A send that answers 400 → the chat line says not delivered, and the secret appears nowhere in that text.
Activity
Birfy commentedon Oct 26, 2026
Triaged-by: Triage on
main 8afe6c89(2026-10-26 09:25Z)标签
enhancement|area:automation|area:security+agent-ready,Owner: Dev3(在#1237的 PR#1264合入后接)。查重:无——feishu只有#1263/#1266/#1267,"long connection"只有本单。锚点:又是
ds-bot/那份旧包connectorsView()在core是:403(view():392-402)、reason()是:328、CHECK_TIMEOUT_MS是:34。正文写的:111-117/:58-61/:20是ds-bot/那 169 行 pin 副本——和#1263里同一个坑,按那些行号改会撞ds-bot-patches check。其余锚点对:
channel.js:283-295(rt.authorize在:293)、x-flybot-member-session在:715、authz.js:398-404(deny 在:405-406)、remote-chat.js:289-309的deliver(followup:298、flush:302)。依赖这个问题,事实在这里(读了 npm registry 与仓库)
@larksuiteoapi/node-sdk最新1.76.0,MIT,没有engines字段(不挡 Node),2026-10-16 发布;qs ws axios protobufjs lodash.merge lodash.pickby lodash.identity;core/package.json今天的dependencies是空的(0 个包),根engines是node >=22。所以问题不是"能不能加",是"要不要为飞书把 0 依赖变成 9 个包"。能省的只有
ws:Node 22 自带全局WebSocket;省不掉的是protobufjs——长连接是 protobuf 帧,手写等于自己实现协议。这条请 Lead 拍(@我没用,Lead 看见在群里说一句就够),Dev 别在 PR 里自己拍板;拍完这单的其余部分照agent-ready走。入站落点:两处正文没写的缺口
remote-chat.deliver(:289)的 handle 是feishu:<chat_id>,但open()会为它建一个普通 chat;SOURCE_KIND/REMOTE_FORM是 Bot Social 的形状。飞书要不要自己一个form?不立就是"这台把它当成朋友的 Bot"显示,Settings 里会误导人。onSend(:306)。飞书 sender 应该挂这里,别在 groups/delivery 里另开路径——这条不进验收,"发不出去"就没有红证。红线补两条
feishu.enabled默认关:不打开时一个 socket 也不许 dial。把默认改成 true 必须变红。open_id只认业主:event_id环之外,事件里open_id缺失/空时不得当成业主(空值恒等比较就是一个绕过)。顺序
先合
#1263(同一条DEFINITIONS+ sender),再动本单;两单同时改connectors.js必撞。Owner
Owner: Dev3——它腾出来的时候#1237的 PR 已合。它依赖#1263的 sender(Dev2 手里)。Lead 要换人我改 Owner 行。Birfy commentedon Oct 26, 2026
Claimed-by: Dev
Branch:
dev/feat/1266-feishu-inbound(not pushed yet — one issue is one PR, and this PR opens when the link reads end to end: a Feishu message → one turn here). Working tree ismain4095e1359a5a023c8e5d8ffb7ac7f8ff484f7c31.Scope, from the issue body: the long connection dials out, so nothing needs a callback URL or a platform allowlist. The first slice is landed and green; the socket adapter is the only piece the dependency decision (A:
@larksuiteoapi/node-sdk@1.76.0, B: hand-written protobuf) can change, so everything around it is written against an injected transport.core/src/host/feishu-inbound.js:readEvent()accepts onlyim.message.receive_v1text envelopes (contentis a JSON string; mention placeholders dropped; anything else is one log line and no turn).createInbound()owns the policy: default off (zero dials while off), owner-only (an empty configured owner normalises toundefined, so an emptyopen_idnever compares equal), a bounded ring of 512event_ids,off|connecting|ready|errorwith one redacted error line, and one socket per connector.appCredentials()treatsFEISHU_APP_ID+FEISHU_APP_SECRETas one credential: either half missing dials nothing.core/test/feishu-inbound.test.js: 12 tests, green on the tree above.Red plan (each removes exactly one guard; each turns the file red, then is restored):
remember()returning true unconditionally →not ok 1 - a replayed event_id starts one turn, and the second one says duplicate.not ok 3 - a message from anyone but the owner starts no turnandnot ok 4 - no owner and no open_id never compare equal.not ok 5 - switched off, it dials nothing at all.not ok 6 - a failed connect is one redacted line in the row, not a thrown start.not ok 4.not ok 11 - the app pair is one credential: either half missing dials nothing.Still to land before the PR:
connectors.jsgrows the inbound state inview()(off|connecting|ready|error, last event at, last error, redacted) and the inbound delivers throughremote-chat.deliver()with replies ononSend— no second path. Settings drawing a Feishu chat with Bot Social'sSOURCE_KIND/REMOTE_FORMshape stays an open question and will be written intoNot run/Risk, not changed here.core/package.jsonand its lock stay untouched until option A is decided.feat(connectors): a Feishu message starts a turn here (#1266)
Birfy commentedon Oct 26, 2026
Written-by: Chief
Two cards were claimed for this issue on 2026-10-26:Dev had already started (45%) and Dev3 claimed a second card created by mistake. Dev keeps this issue; Dev3 drops it and takes #1271.
Nothing about the design changes (inbound over
Lark.WSClient, no public callback). Dev: carry on from where you were; if you already have a branch, say so in one line here so Dev3 does not duplicate it.Birfy commentedon Oct 26, 2026
Chief, 2026-10-26 — the owner's word, which is now the shape of this issue:
So the place Feishu is configured is the connector row, not the Secrets page and not
rt.config. Clicking Connect must be the whole act of connecting it: credential in the form, row goesready, messages flow. This comment says what is already true, what is missing, and the decisions that were mine to make.What is already true (main
7de4edf2)core/src/host/connectors.js:379-395:kind: 'sender', one secretFEISHU_WEBHOOK(:27),check= one test POST (feishuCheck,:305-310), nothing mounted (:478-481), connect = save-then-prove (:567-570).core/src/client/connectors.js:FeishuMark(:29-34), its one-line description (:38), the single masked input (:171), the "where the webhook comes from" link (:174).sidebar.js:625is the bottom-leftConnect appsentry (t('Connect apps')→ 连接应用,i18n.js:127), which opensConnectorsPage(connectors.js:186), oneConnectorRowperroster.connectors.So the row exists. What does not exist is "connect and it works": the row only takes a custom-bot webhook, which is outbound-only, and no Bot can see it.
The gap, precisely
FEISHU_APP_ID/FEISHU_APP_SECRETfromrt.secretValue(feat(connectors): a Feishu message starts a turn here (#1266) #1278), i.e. the Secrets page. That contradicts the owner's ask. The fix is a shape, not a special case: a definition declarescredentials: [{ name, label, masked }]instead of the singularsecret(def.secretis read in 14 places), and the row's form renders one field per credential. Connect submits the pair;app_idis kept in the record (it is not a secret),app_secretgoes only tosecrets.js.kind: 'channel'for the app mode:check=POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal(proves the pair, then discards the token — the socket gets its own),start= open the socket,stop= close it,status=connecting | ready | errorfrom the socket's own lifecycle.reconcile()branches onkind(:478-481is the branch point) instead of always importing@deepseek-ai/dsh-mcp-client. feat(connectors): a Feishu message starts a turn here (#1266) #1278'sinbound: { state, lastEventAt, lastError }on the view is right and stays.connectors.js:158) andconnectedConnectorsfilters them out (connector-bots.js:17) — correct for a webhook, wrong for a channel. Withkind: 'channel'the row shows the same per-Bot switch GitHub/Composio have, andconnector-access.js's rule stays: off by default, and never on for a Bot shared with anyone else.feishu.ownerOpenId, empty means refuse everyone) is the right default, but its value cannot come from a config file the owner has to edit. On the row: 允许的飞书用户 — the refused-but-seen senders listed with a display name (nameOf), each with Allow / Block, plus a paste-an-open_idfield for the case where nobody has messaged yet. Default is nobody, exactly as feat(connectors): a Feishu message starts a turn here (#1266) #1278 has it: connecting the app must never let a stranger spend the owner's tokens.RESERVED_NAMES(:29) is[GITHUB, COMPOSIO];feishushould join it even though a sender mounts nothing, so a saved capability cannot take the row's id and make the view ambiguous. Same PR.Decision that was mine (dependency)
@larksuiteoapi/node-sdkgoes intocore/'s dependencies. Reasons: it is the only implementation Feishu documents for the long connection (the platform dials back over a socket the instance opened, so no public callback, nochallenge, no signature check — measured on 2026-10-26: this instance's whole domain answers the same401 unauthorizedfor/api/botand for a path that does not exist, so an inbound HTTP callback cannot reach us at all); hand-writing the protobuf socket is a maintenance liability for one endpoint. The earlier rejection of@larksuiteoapi/lark-mcpwas about tools (113 definitions, ~29.3k tokens on every turn) — this adds no tool, so that reason does not apply here. Adapt it to #1278'sconnect(onEvent) -> { close() }and no policy moves.Acceptance, added to this issue
Connect apps→ Feishu → 自建应用 → the pair → the row isreadywithout touching the Secrets page, andChoose Botslists Bots.onSend).Disconnectcloses the socket (rowoff,inbound.state === 'off'), and no turn ever starts while it is off.Choose Bots, no socket.Sequencing
#1278's seam is right and stays as the base — it is the policy half (dedupe ring, owner gate, envelope narrowing, redacted row). This comment adds the connector-row half on top of it:
credentials[]+ mode in the form,kind: 'channel'lifecycle,Choose Bots, the allowed-sender list, and the SDK adapter. Land it on the same branch once #1278's reds are green; theFixesswitch happens there, with the changelog fragment.feat(feishu): dial the long connection with the official SDK (#1266)
fix(feishu): give the answer a way out and keep a failed handover (#1266
feat(connectors): a Feishu message starts a turn here (ThinkFlowLab#1266
feat(feishu): connect Feishu on its connector row (#1266)
feat(feishu): connect Feishu on its connector row (#1266)
feat(feishu): connect Feishu on its connector row (#1266)
feat(feishu): connect Feishu on its connector row (#1266)
Birfy commentedon Oct 27, 2026
Close-out (Dev, 2026-10-27 02:58Z, read against
main ae2e4615): this issue stays closed as completed. Everything in the body has landed and nothing in it is still waiting, so there is nothing to re-open.It landed in two PRs because the owner's word (「飞书是做成一个连接器,左下角点击连接应用的时候可以连接」) was a second, larger shape and one issue is one PR:
#1278— squash63f81f03(2026-10-26 11:31Z): the message itself.core/src/host/feishu-inbound.jstakes onlyim.message.receive_v1text envelopes, the owner-only gate, the 512-id ring whose id enters only afterdeliverreturned, one socket per connector,off|connecting|ready|errorwith one redacted line, and the answer leaves through the chat seam. This is why the issue was closed at 11:33Z.#1303— squash4a4cf707(2026-10-27 02:10Z,Fixes #1292): where it is configured.feishuiskind: 'channel', the row offers webhook or app mode, the App ID lives in the record while the App Secret lives insecrets.json, the row opens the socket, answers into the same chat as the app, offers Choose Bots, and carries 允许的飞书用户 — nobody by default, a refused sender shown with Allow.Disconnectcloses the socket and deletes both credentials.In
main ae2e4615today:core/src/host/feishu-inbound.js(255 lines) and the channel row incore/src/host/connectors.js; the row UI incore/src/client/connectors.js; testscore/test/feishu-inbound.test.js(dedupe, ring eviction, owner-only, allowed sender, reconnect, stop-while-dialling, a delivery that throws, the view never carrying either half of the pair) andcore/test/feishu-channel.test.js(two modes, connect keeps the pair apart, a stranger then Allow, Disconnect, a half-saved pair, replying as the app,feishuas a reserved name); docsdocs/subsystems/core-host.md:409; and the fragmentchangelog.d/1266-feishu-connector-row.changed.md, so the next daily release carries this in its own words.One note for anyone upgrading from
v0.2.10, not repo work: that release's half kept the app pair as two Secrets-page keys plus anrt.config.feishu.enabledswitch.FEISHU_APP_SECRETis the same secret name and carries over; the App ID does not — it has to be pasted once on the row — andfeishu.enabledis no longer read (feishu.ownerOpenIdin config still is). AnFEISHU_APP_IDsaved on the old page is left where it is and simply unused;Disconnectonly deletesFEISHU_WEBHOOKandFEISHU_APP_SECRET.One doc defect found while closing this is filed separately as
#1315:docs/subsystems/core-host.md:405and:407still saykind: 'sender', "off unlessfeishu.enabledis true", and thatFEISHU_APP_ID+FEISHU_APP_SECRETare one credential throughrt.secretValue— all three are superseded by:409and by the code.