Skip to content

Feishu connector, phase 2: a message in Feishu starts a turn here (inbound over the long connection) #1266

Closed

Description

@Birfy

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/bot with {"endpoint":"state"} → 401, body unauthorized, 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 challenge handshake and no X-Lark-Signature verification, 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

  1. 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).
  2. One identity, and only the owner's. Feishu open_id → the instance's owner. A message from any other open_id is 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.
  3. Reconnect without double-speaking. After a dropped socket, the client reconnects; the same event_id arriving twice must land once, and a reply must not be sent twice.
  4. 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.
  5. 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_id ring 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

  1. Feed one event with event_id: e1 twice → exactly one turn, and the second one logsduplicate; remove the ring and the test sees two turns.
  2. An event whose open_id is not the owner's → zero turns, one warn; remove the check and the test sees a turn.
  3. Drop the socket between two events → both events still land once each (a reconnect test with a fake WS).
  4. A send that answers 400 → the chat line says not delivered, and the secret appears nowhere in that text.

Activity

  1. Birfy commented on Oct 26, 2026

    @Birfy
    CollaboratorAuthor

    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 发布;
    • 它带 8 个传递依赖: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 走。

    入站落点:两处正文没写的缺口

    1. remote-chat.deliver(:289)的 handle 是 feishu:<chat_id>,但 open() 会为它建一个普通 chat;SOURCE_KIND/REMOTE_FORM 是 Bot Social 的形状。飞书要不要自己一个 form?不立就是"这台把它当成朋友的 Bot"显示,Settings 里会误导人。
    2. 回复怎么回飞书:今天唯一的口子是 onSend(:306)。飞书 sender 应该挂这里,别在 groups/delivery 里另开路径——这条不进验收,"发不出去"就没有红证。

    红线补两条

    1. feishu.enabled 默认关:不打开时一个 socket 也不许 dial。把默认改成 true 必须变红。
    2. open_id 只认业主:event_id 环之外,事件里 open_id 缺失/空时不得当成业主(空值恒等比较就是一个绕过)。

    顺序

    先合 #1263(同一条 DEFINITIONS + sender),再动本单;两单同时改 connectors.js 必撞。

    Owner

    Owner: Dev3——它腾出来的时候 #1237 的 PR 已合。它依赖 #1263 的 sender(Dev2 手里)。Lead 要换人我改 Owner 行。

  2. added
    enhancementNew feature or request
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    area:automationScheduled tasks and automation
    area:securityAuth, grants, approvals, secrets
    on Oct 26, 2026
  3. Birfy commented on Oct 26, 2026

    @Birfy
    CollaboratorAuthor

    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 is main 4095e1359a5a023c8e5d8ffb7ac7f8ff484f7c31.

    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 only im.message.receive_v1 text envelopes (content is 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 to undefined, so an empty open_id never compares equal), a bounded ring of 512 event_ids, off|connecting|ready|error with one redacted error line, and one socket per connector. appCredentials() treats FEISHU_APP_ID + FEISHU_APP_SECRET as 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):

    1. Dedupe ring. remember() returning true unconditionally → not ok 1 - a replayed event_id starts one turn, and the second one says duplicate.
    2. Owner gate. Drop the sender check → not ok 3 - a message from anyone but the owner starts no turn and not ok 4 - no owner and no open_id never compare equal.
    3. Default off. Dial even when disabled → not ok 5 - switched off, it dials nothing at all.
    4. Redaction. Report the raw connect error → not ok 6 - a failed connect is one redacted line in the row, not a thrown start.
    5. Owner normalisation. Treat an empty configured owner as an id → not ok 4.
    6. App pair. Accept half a credential → not ok 11 - the app pair is one credential: either half missing dials nothing.

    Still to land before the PR: connectors.js grows the inbound state in view() (off|connecting|ready|error, last event at, last error, redacted) and the inbound delivers through remote-chat.deliver() with replies on onSend — no second path. Settings drawing a Feishu chat with Bot Social's SOURCE_KIND/REMOTE_FORM shape stays an open question and will be written into Not run/Risk, not changed here. core/package.json and its lock stay untouched until option A is decided.

  4. added
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Oct 26, 2026
  5. Birfy commented on Oct 26, 2026

    @Birfy
    CollaboratorAuthor

    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.

  6. Birfy commented on Oct 26, 2026

    @Birfy
    CollaboratorAuthor

    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 goes ready, 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)

    • The host already defines Feishu as a connector, core/src/host/connectors.js:379-395: kind: 'sender', one secret FEISHU_WEBHOOK (:27), check = one test POST (feishuCheck, :305-310), nothing mounted (:478-481), connect = save-then-prove (:567-570).
    • The client already draws the row, 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).
    • The row is reached exactly as the owner said — sidebar.js:625 is the bottom-left Connect apps entry (t('Connect apps') → 连接应用, i18n.js:127), which opens ConnectorsPage (connectors.js:186), one ConnectorRow per roster.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

    1. The credential is in the wrong place. The app-credential path reads FEISHU_APP_ID / FEISHU_APP_SECRET from rt.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 declares credentials: [{ name, label, masked }] instead of the singular secret (def.secret is read in 14 places), and the row's form renders one field per credential. Connect submits the pair; app_id is kept in the record (it is not a secret), app_secret goes only to secrets.js.
    2. One row, two modes. Do not add a second "Feishu" row — two rows with the same name is a puzzle, not a feature. The row's connect form gets a mode: 自定义机器人 webhook(只出不进,today's behaviour)or 自建应用(App ID + App Secret,双向,长连接). The status line says which mode is live; Disconnect closes both.
    3. The lifecycle belongs to the connector, not to the plugin start. Add 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 | error from the socket's own lifecycle. reconcile() branches on kind (:478-481 is the branch point) instead of always importing @deepseek-ai/dsh-mcp-client. feat(connectors): a Feishu message starts a turn here (#1266) #1278's inbound: { state, lastEventAt, lastError } on the view is right and stays.
    4. "Choose Bots" must appear for the app mode. Today it is hidden for senders (connectors.js:158) and connectedConnectors filters them out (connector-bots.js:17) — correct for a webhook, wrong for a channel. With kind: 'channel' the row shows the same per-Bot switch GitHub/Composio have, and connector-access.js's rule stays: off by default, and never on for a Bot shared with anyone else.
    5. Who may talk to the Bots is part of the row. feat(connectors): a Feishu message starts a turn here (#1266) #1278's owner gate (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_id field 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.
    6. Small finding while reading: RESERVED_NAMES (:29) is [GITHUB, COMPOSIO]; feishu should 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-sdk goes into core/'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, no challenge, no signature check — measured on 2026-10-26: this instance's whole domain answers the same 401 unauthorized for /api/bot and 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-mcp was 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's connect(onEvent) -> { close() } and no policy moves.

    Acceptance, added to this issue

    1. With nothing configured, Connect apps → Feishu → 自建应用 → the pair → the row is ready without touching the Secrets page, and Choose Bots lists Bots.
    2. A message the owner sends in Feishu starts one turn in the chosen Bot's own session, and the answer arrives back in the same Feishu chat (through the phase-1 sender, onSend).
    3. A message from anyone not on 允许的飞书用户 starts no turn, appears in the row as one refused sender, and can be allowed from the row.
    4. Disconnect closes the socket (row off, inbound.state === 'off'), and no turn ever starts while it is off.
    5. Webhook mode keeps today's behaviour exactly: outbound only, no Choose Bots, no socket.
    6. A half-saved pair (one of the two) dials nothing and says so on the row, as feat(connectors): a Feishu message starts a turn here (#1266) #1278 already pins in its tests.

    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; the Fixes switch happens there, with the changelog fragment.

  7. removed
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Oct 26, 2026
  8. removed
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Oct 26, 2026
  9. Birfy commented on Oct 27, 2026

    @Birfy
    CollaboratorAuthor

    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 — squash 63f81f03 (2026-10-26 11:31Z): the message itself. core/src/host/feishu-inbound.js takes only im.message.receive_v1 text envelopes, the owner-only gate, the 512-id ring whose id enters only after deliver returned, one socket per connector, off|connecting|ready|error with one redacted line, and the answer leaves through the chat seam. This is why the issue was closed at 11:33Z.
    • #1303 — squash 4a4cf707 (2026-10-27 02:10Z, Fixes #1292): where it is configured. feishu is kind: 'channel', the row offers webhook or app mode, the App ID lives in the record while the App Secret lives in secrets.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. Disconnect closes the socket and deletes both credentials.

    In main ae2e4615 today: core/src/host/feishu-inbound.js (255 lines) and the channel row in core/src/host/connectors.js; the row UI in core/src/client/connectors.js; tests core/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) and core/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, feishu as a reserved name); docs docs/subsystems/core-host.md:409; and the fragment changelog.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 an rt.config.feishu.enabled switch. FEISHU_APP_SECRET is the same secret name and carries over; the App ID does not — it has to be pasted once on the row — and feishu.enabled is no longer read (feishu.ownerOpenId in config still is). An FEISHU_APP_ID saved on the old page is left where it is and simply unused; Disconnect only deletes FEISHU_WEBHOOK and FEISHU_APP_SECRET.

    One doc defect found while closing this is filed separately as #1315: docs/subsystems/core-host.md:405 and :407 still say kind: 'sender', "off unless feishu.enabled is true", and that FEISHU_APP_ID + FEISHU_APP_SECRET are one credential through rt.secretValue — all three are superseded by :409 and by the code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:automationScheduled tasks and automationarea:securityAuth, grants, approvals, secretsenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions