Skip to content

feat(connectors): Feishu sender through a custom-bot webhook (#1263) - #1269

Merged
Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev2/fix/1263-feishu-sender
Oct 26, 2026
Merged

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev2/fix/1263-feishu-sender

Conversation

@HugoXK

@HugoXK HugoXK commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

feat(connectors): Feishu sender through a custom-bot webhook (#1263)

Fixes #1263

Owner: Dev2
Reviewer: Reviewer
Written-by: Dev-2

Triage graded this agent-ready and set Owner: Dev2 (comment 6411621477); the product decision is the owner's 2026-10-26 request for Feishu support. Phase 1 only: outbound, one instance-wide webhook, no inbound, no MCP server.

What changed

core/src/host/connectors.js learns a second shape next to the mount. A definition with kind: 'sender' gets a status and one POST — nothing is mounted, no Bot's tool list changes, and connector-access.js keeps its two ids (a sender has no tools, so a per-Bot switch would change nothing).

  • Connect — the webhook is saved as FEISHU_WEBHOOK first, then proved with one test message. A refusal stays on the row as Could not connect: … with Try again beside it (the same shape a failed MCP mount has), because the URL is already a saved secret and redactSecrets can mask it. The URL must be https on the configured feishu.api origin: it is the credential, so it is never posted to another host.
  • No re-post on restart — the test POST belongs to the connect step, not the start-up step, so a restart marks a saved webhook ready without messaging the group; connector-retry ("Try again") is the one path that proves it again.
  • Send — connector-send { id, text } (new, in channel.js and SHARED_BLOCKED) posts one message on demand and records lastOkAt / lastError in state.connectors.feishu. Nothing posts room traffic by itself.
  • Retry — Feishu answers HTTP 200 with the verdict in the body (code: 0 success, 9499 rate limit, 19021 refused signature), so feishuPost maps the body into notify.js's vocabulary and the policy stays notify.js's createNotifier (3 attempts, backoff, retry-after honoured, structural errors not retried). No second retry loop next to the mount code. Each send uses a fresh idempotency key, so two messages with the same text are two messages.
  • Bounded — connect has a 15 s deadline, a send 60 s, and withDeadline races the fetch, so a fetch that ignores its abort signal cannot hang either.
  • Client — src/client/connectors.js: the row's input is Feishu webhook URL, its link goes to Feishu's custom-bot page (never GitHub's token page), and when ready it carries a text field with Send. connectedConnectors drops senders, so no Bot settings card offers a switch for it. core/client.js rebuilt with npm run build; core/src/client/i18n.js has the zh entries.

Red first

core/test/feishu.test.js, 8 cases, all red on main (SyntaxError: … does not provide an export named 'feishuPost'), green here. The two that carry the bug's shape beyond "it exists":

  • a rate limit in the body is retried, a refused signature is not — code: 9499 must be attempts=2 sent; code: 19021 must be attempts=1 failed, permanent. Both arrive as HTTP 200; classifying by status would never retry the one case that deserves a retry.
  • connecting posts one test message, turns ready, and keeps the webhook out of state, disk and the log — one POST, state === 'ready', and the token absent from the view, from state.json and from every argument of every rt.warn call (that last one is where a pasted-and-not-yet-saved secret would leak).

Knife, on this tip, each reversion restored afterwards:

reversion red
9499 classed as permanent not ok 5 - a rate limit in the body is retried, a refused signature is not
kind: 'sender' removed from the definition not ok 1, 2, 3, 6
the row's link back to GitHub's token page not ok 7 - the Feishu row asks for a webhook URL and links to Feishu, not to GitHub

Trade-offs, deliberate

  1. A refused webhook stays saved. It is the cheapest way to keep a reason on the row and Try again meaningful, and it matches a failed mount. The cost: until the owner disconnects, an unusable URL sits in the secret store and in every Bot's shell as $DSH_SECRET_FEISHU_WEBHOOK. Disconnect clears it.
  2. A restart trusts the saved webhook. No POST means a webhook revoked on Feishu's side reads ready until someone sends through it or presses Try again. Posting on every start was rejected: it would put a message in the group on each launch.
  3. Only the webhook path is here. App credentials (tenant_access_token + im/v1/messages) slot in behind the same post() for @-mentions, cards and phase 2 — the definition is the seam, so callers do not change.

Ran

TZ=UTC npm run verify → exit 0 at the tip: core 1682/1682, social 525/525, tree 125/125, timeline 125/125, .github/scripts 508/508, interop 37/37, live-e2e 24/24; arch-docs: OK (20 changed file(s)); ok 10 patches apply cleanly in order; changelog check green; client.js build check green; lint 0 errors.

Not run: the smoke test (no change to index.js startup wiring; the new endpoint is reached through the same api/bot route the connector endpoints already use).

patches/ds-bot/ regenerated (0001, 0003, 0005, 0008, files/); ds-bot/ untouched.

Checkpoint

{"pr": 1269, "claim": "Dev2", "issue": 1263, "summary": "Feishu phase 1: the connector host gains a sender shape that mounts nothing and posts one text message through a group's custom-bot webhook. Connect proves the webhook with one test POST, a refusal stays on the row with its reason and no URL anywhere, and a restart does not post again. connector-send posts on demand under notify.js's retry policy, so a body-level 9499 rate limit is retried and a 19021 refused signature is permanent. The Settings row asks for a webhook URL, links to Feishu, and offers a send field instead of a per-Bot switch it cannot use.", "files": ["core/src/host/connectors.js", "core/src/host/channel.js", "core/src/host/authz.js", "core/src/client/connectors.js", "core/src/client/connector-bots.js", "core/src/client/i18n.js", "core/test/feishu.test.js", "docs/subsystems/core-host.md", "docs/subsystems/core-client.md", "changelog.d/1263-feishu.added.md"], "checks": {"ran": "TZ=UTC npm run verify", "result": "green", "at": "a3bc41ec0e3a8d85adb79584b13ae00bd136a57c"}, "next": "Reviewer at this tip; then Test and Security. Phase 2 (inbound) is a separate issue."}

… webhook (ThinkFlowLab#1263)

Feishu is not an MCP server: a group's custom-bot webhook is one HTTPS POST, so
the connector host needs a second shape next to the mount. A definition with
kind 'sender' gets a status and one POST instead: nothing is mounted, no Bot's
tool list changes, and connector-access keeps its two ids.

The test POST is the connect step, not the start-up step, so a restart never
messages the group; a saved webhook reads ready and Try again is the one path
that proves it again. The webhook is saved before the probe, so a refusal stays
on the row with its reason and redaction can mask it, and it must be https on the
configured origin because the URL is the credential.

Feishu answers HTTP 200 with the verdict in the body, so feishuPost maps code
9499 to notify.js's rate limit and any other code to a permanent refusal, and the
retry policy stays notify.js's instead of a second copy here.

connector-send posts one message on demand and records lastOkAt/lastError; the
Settings row grows a send field and loses the per-Bot switch a sender cannot use.
@HugoXK

HugoXK commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Written-by: Dev-2

PASS request at a3bc41ec0e3a8d85adb79584b13ae00bd136a57c. The head is frozen from now on: changes go through the reviewer first, and a new commit voids this request.

What to read: core/src/host/connectors.js (the kind: 'sender' branch in reconcile, probe, connector-send, feishuPost's body-code classification), core/src/host/channel.js + authz.js (the endpoint and SHARED_BLOCKED), core/src/client/connectors.js (the row), core/test/feishu.test.js (8 cases, all red on main), and the trade-offs section of the body — item 1 (a refused webhook stays saved) is the one worth arguing with.

TZ=UTC npm run verify at this tip: exit 0 (core 1682, social 525, tree 125, timeline 125, .github/scripts 508, interop 37, live-e2e 24; arch-docs: OK (20 changed file(s)); patches check ok). Knife table is in the body. Not run: the smoke test.

Tier: not T1 — new host surface plus a rebuilt client bundle, so this needs the verdict.

@Birfy

Birfy commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at a3bc41e
Written-by: Reviewer
Attestation: a3bc41e role=Reviewer

Judged on the tree at this tip (fork head). One body-form note first: the body has no Tip SHA: line — the checkpoint's checks.at names this sha, and the guard exempts a fork from the checkpoint, label and issue rules, so I record it rather than hold the tip for it.

What I checked

  • 20 files, and they belong together: core/src/host/connectors.js (+174), channel.js (+1 case), authz.js (one name in SHARED_BLOCKED), the two client modules + i18n.js + index.js (the connectorSend action), core/client.js (rebuilt), core/test/feishu.test.js (new, 8 cases), core/test/connectors.test.js (the feishu: false row), docs/subsystems/core-host.md / core-client.md, one changelog fragment, and the regenerated patches/ds-bot/0001/0003/0005/0008 + three files/ mirrors. No stray product code, no store.js/task-drive.js touch.
  • The sender shape does what the body says. DEFINITIONS.feishu carries kind: 'sender', mountKey: () => '', account: () => null, and sync() returns through a sender branch before any loader work: it enables the notifier and marks the row ready without posting when it is not a forced run, so a restart (or a secret change) never messages the group; force — connect and Try again — is the only path that calls probe(), which posts exactly one test message. connector-access.js is untouched, so CONNECTOR_IDS stays [github, composio] and no Bot's tool list changes.
  • The webhook is treated as the credential. webhookAddress() requires https and the configured feishu.api origin before anything is sent, and it throws the reason (A Feishu webhook must be https on <origin>) without the URL. The connect path saves the secret first, then probes, so a refusal stays on the row as error with Try again — the declared trade-off. The view reports kind and never the URL; feishuCheck/feishuPost/probe messages carry only Could not reach Feishu, the origin, or feishu/<code>, and the test keeps every warn argument and asserts the token appears in none of them, in state.json, or in the state answer.
  • The verdict is the body, not the status. feishuPost maps code: 0 → sent, 9499 → rate_limited (retryable), 429 → rate_limited with retry-after, 5xx → service_unavailable, everything else → feishu/<code> (permanent). The retry budget stays notify.js's createNotifier — one transport per sender, reading the saved webhook at call time — instead of a second loop.
  • One message, on demand, once. connector-send is in channel.js and SHARED_BLOCKED, refuses an empty text, refuses before connecting, and keys the notifier with randomUUID() so two identical texts are two messages; lastOkAt / connector-connect's connectedAt land in state.connectors.feishu, lastError on a refusal. Deadlines are real: 15 s for the probe, 60 s for a send, and withDeadline races the fetch so an abort-ignoring fetch cannot hang either (the deadline case is tested with a fetch that never settles).
  • Client: the row's input is Feishu webhook URL, its link is Feishu's custom-bot page, a ready sender carries a text field + Send, and connectedConnectors drops kind === 'sender', so no Bot-settings switch is offered. i18n.js has a zh entry for each new string and Send now appears once. core/client.js is regenerated — build-client --check exits 0.
  • Docs match the code: the sender paragraph names the same save-then-probe order, the same SHARED_BLOCKED, the same 15 s/60 s, the same notify.js policy and the fresh key per call.

Knives, all three re-cut here

reversion red
9499 classed as permanent (feishuPost, :299) not ok 5 - #1263: a rate limit in the body is retried, a refused signature is not — 7 pass / 1 fail
kind: 'sender' removed from the definition not ok 1, 2, 3, 6 — 4 pass / 4 fail
the row's link back to GitHub's token page not ok 7 - #1263: the Feishu row asks for a webhook URL and links to Feishu, not to GitHub — 7 pass / 1 fail

Each was restored and checked by md5 (connectors.js 300712c151d11b352687566b4cb24759, client 18621fa0302534667159d8c4356a3133) before the next one. Red-on-main is consistent with what I read: main's connectors.js has 0 occurrences of feishuPost, so the new file cannot even import there.

Ran

Ran: from the tip tree (TZ=UTC, DSH 0.2.0-rc.2 packages linked), after restoring every probe

$ node --test --test-concurrency=1 test/feishu.test.js test/connectors.test.js -> # tests 37 / # pass 37 / # fail 0
$ node --test --test-concurrency=1 test/connectors.test.js test/feishu.test.js test/connector-access.test.js test/connector-apps.test.js test/connector-bots.test.js test/client-bundle.test.js test/notify.test.js test/secrets.test.js test/authz.test.js test/api-route.test.js test/client-apply-order.test.js test/i18n.test.js test/tool-schema.test.js test/hidden-tool-removal.test.js -> # tests 132 / # pass 132 / # fail 0
$ node scripts/ds-bot-patches.mjs check -> skipped pin (no git) / ok 10 patches apply cleanly in order / ok core/ equals ds-bot/ + patches/ds-bot/
$ node scripts/build-client.mjs --check -> exit 0
$ cmp core/src/client/connector-bots.js patches/ds-bot/files/src/client/connector-bots.js -> exit 0
$ cmp core/src/host/authz.js patches/ds-bot/files/src/host/authz.js -> exit 0
$ cmp core/test/feishu.test.js patches/ds-bot/files/test/feishu.test.js -> exit 0

Not run here: a live call to Feishu. Every POST in the tests is a fake fetch; sending to a real webhook would post into a real group, which is the owner's call, not a review step.

Three low notes, none blocking

  1. connector-send never re-validates the address: webhookAddress() is called only inside feishuCheck (:306), so a FEISHU_WEBHOOK that arrives some other way — the Secrets page, or a Bot's request_secret card — shows ready after a restart and is posted to exactly as saved. redirect: 'error' keeps it from bouncing to a second host and the value is the owner's own credential, so this is a gap to record, not a hole to close on this tip.
  2. lastError is never cleared by a later success ({ ...saved(c), lastOkAt }), so state.connectors.feishu.lastError can outlive the failure it describes. It is not surfaced in the view, so nothing reads it wrong today.
  3. notify.js's sent map keeps one entry per manual send (keyed by a fresh UUID) and is never pruned: a few bytes per Send, per process. Worth a look if sends ever become automatic.

@github-actions github-actions Bot added the pass label Oct 26, 2026
@Birfy
Birfy added this pull request to the merge queue Oct 26, 2026
Merged via the queue into ThinkFlowLab:main with commit 779d2b3 Oct 26, 2026
12 of 13 checks passed
@Birfy

Birfy commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

0005-social-seams 对账(合入后结案)

结论:0005 承载本单 1 行改动 —— core/src/host/channel.js:494 的 case 'connector-send':。 删掉该行并把 hunk 头调成 @@ -231,8 +231,11 @@ 后重跑,check 第三行变红并点名 core/src/host/channel.js,所以是「承载」,不是漂移:不还原、不新推 commit、不开还原 PR。

对账由 Dev2 完成;本条由实现 Lead 转贴,数字以 Dev2 自己的评论 6413161660 与 pulls/1269/files 为准。

patch 本单真改到的源文件段 patch 文本增量(API) 源文件增量
0001-core-fixes.patch src/host/connectors.js、src/client/connectors.js、src/client/i18n.js、test/connectors.test.js +202 / −1 净 +201
0003-flybot-api-client.patch 仅 src/client/index.js(connectorSend 一行) +2 / −1 +1 / −0
0005-social-seams.patch 仅 src/host/channel.js +4 / −1 +1 / −0
0008-client-bundle.patch client.js(bundle 重建) +155 / −127 core/client.js +36/−8,净 +28

0005 的 +4/−1 是什么

+4 = 1 行 payload(case 'connector-send':)+ 1 行新 hunk 头 + 2 行新吸收的上下文;−1 = 旧 hunk 头。hunk 头从 @@ -231,6 +231,9 @@ 挪成 @@ -231,8 +231,12 @@,patches/ds-bot/topics.json 本就把 src/host/channel.js 记在 0005-social-seams 名下("social seams" 指 room/channel 接缝,不是 social 功能)。

更正:我先前的转贴写成 +4/−5,那是 Dev2 的一次口头估算。pulls/1269/files 与 commits/a3bc41ec 的 files 都报 additions: 4, deletions: 1(净 +3,文件 3036→3039 行),以这一组为准。

判据

  • 删除即红:删掉 case 'connector-send':、hunk 头调回 @@ -231,8 +231,11 @@ 后重跑,check 第三行红并点名 core/src/host/channel.js。
  • 双侧逐行相等:head patch 还原出的文件与 head core/ 逐行相同;base patch 与 base core/(8afe6c89)也逐行相同(host/connectors 719、client/connectors 195、i18n 1427、test/connectors 830、client/index 1027、channel 758、client.js 20243 行)。
  • check 原文:
ok   10 patches apply cleanly in order
ok   core/ equals ds-bot/ + patches/ds-bot/

pin 行在 Dev2 的机器上是 skipped (no git),由 CI 的 package 作业覆盖。三份 files/ 镜像逐字节一致:connector-bots.js 033a471e、authz.js c71ed831、feishu.test.js 5514f101。

@Birfy

Birfy commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

0005 承载本单 1 行改动,属预期,不还原。

核对对象:patches/ds-bot/0005-social-seams.patch,base 8afe6c89(blob 9834a17a)→ tip a3bc41ec(blob 28b0f89e,与 main 779d2b38 同一 blob)。

1. 逐条落点:只有 1 个 source 段变了

  • 段:diff --git a/src/host/channel.js b/src/host/channel.js(base 682 行 / tip 685 行)。同 patch 其余 4 段 src/client/characters.js、src/client/mark.js、src/host/delivery.js、test/rounds.test.js 与 base 逐字节相同。
  • 差量集中在 base 第 722 行起的那个 hunk(tip 第 722 行起,同一个 hunk):
    • −1:旧 hunk 头 @@ -231,6 +231,9 @@ export function install(rt) {
    • +1:新 hunk 头 @@ -231,8 +231,12 @@ export function install(rt) {
    • +1:payload 行 + case 'connector-send':,紧随 case 'connector-retry':、紧邻 await rt.load() / return ok(await rt.connectorEndpoint(endpoint, body));实际落点 core/src/host/channel.js:494(本 patch 系列 hunk 的新侧起点照抄旧侧起点,+231 不是输出文件行号)
    • +2:该 hunk 顺带带上的两条上下文 return ok(await rt.connectorEndpoint(endpoint, body))、 case 'capability-save':
  • 所以 patch 文本是 +4/−1(pulls/1269/files 与 commits/a3bc41ec 的 files 都报 additions: 4, deletions: 1;净 +3 行 = 文件 3036→3039)。源文件层面 src/host/channel.js 是 +1/−0(757→758 行),就这一行。

2. 四个 patch 各自覆盖的源文件段(base 8afe6c89 → tip)

patch 段 / hunk 真变了的段 源文件行数(API files)
0001-core-fixes 52 / 291 src/host/connectors.js、src/client/connectors.js、src/client/i18n.js、test/connectors.test.js(其余 48 段逐字节相同) +174/−1、+22/−5、+6/−1、+10/−4(净 +201)
0003-flybot-api-client 21 / 131 src/client/index.js +1/−0(connectorSend: (id, text) => after(call('connector-send', { id, text })),)
0005-social-seams 5 / 33 src/host/channel.js +1/−0(case 'connector-send':)
0008-client-bundle 1 / 276 client.js +36/−8(净 +28,npm run build 生成)

本 PR 另有 3 个 core 侧文件走 patches/ds-bot/files/ 镜像、不在任何 .patch 段里:src/client/connector-bots.js +3/−1、src/host/authz.js +1/−1、test/feishu.test.js +256/−0(镜像与 core 逐字节相同)。没有任何 social 源文件改动。

3. topics.json 的分工(原文归属)

  • 0001-core-fixes: src/host/connectors.js、src/client/connectors.js、src/client/i18n.js、test/connectors.test.js
  • 0003-flybot-api-client: src/client/index.js
  • 0005-social-seams: src/host/channel.js、src/host/delivery.js、src/client/characters.js、src/client/mark.js、test/rounds.test.js
  • 0008-client-bundle: client.js

src/host/channel.js 本来就在 0005 名下,所以 0005 变 1 行是这个文件的真改动,不是串段。

4. 两侧逐行相等(7 个文件 × 两侧 = 14/14)

按旧侧起点把每个 hunk 套回 ds-bot/ 原文件(hunk 相互独立):head patch → tip core/ 全对;base patch → base 8afe6c89 的 core/ 也全对。对账用的 base 文件 blob sha1 与 API ?ref=8afe6c89 一致:core/src/host/channel.js 52b498f5、core/src/client/index.js 3594a08c、core/client.js 7a2adeb7、core/src/host/connectors.js 6b0e84c0。

5. check 原文(tip 树)

skipped pin (no git): the tree hash cannot be computed here
ok   10 patches apply cleanly in order
ok   core/ equals ds-bot/ + patches/ds-bot/

pin 行是 skipped (no git)(核对机上没有 git,树哈希算不了),由 CI 的 package job 覆盖。

6. 承载性判别:剔掉这一行后第三行会红

把 + case 'connector-send': 从 0005 删掉、hunk 头相应改成 @@ -231,8 +231,11 @@,在副本里重跑同一命令:

skipped pin (no git): the tree hash cannot be computed here
ok   10 patches apply cleanly in order
ds-bot-patches: core/ is not ds-bot/ + patches/ds-bot/ (edit core/, then run generate; or run apply):
  core/src/host/channel.js: differs from ds-bot/ + patches

结论:0005 承载本单 1 行改动(src/host/channel.js 的 case 'connector-send':,落 core/src/host/channel.js:494),另外 3 行只是 hunk 头与上下文的移动。属预期,不还原、不开 PR、不再推 commit。

HugoXK pushed a commit to HugoXK/FlyBot that referenced this pull request Oct 26, 2026
) (ThinkFlowLab#1278)

Fixes ThinkFlowLab#1266

Owner: Dev
Written-by: Dev
Reviewer: Reviewer
Base: `7de4edf2a513667d0339a64c4129ee142bc06a66` — the parent of this
branch's first commit and current `main` when it was cut
(`commits/main`, read 2026-10-26T10:47Z).
Tip SHA: 0946f4c

## What changed

Phase 1 (ThinkFlowLab#1263, merged as ThinkFlowLab#1269) only posts out. This branch is the
other direction, end to end: a message the owner types in Feishu starts
a turn here, and the answer goes back out to Feishu — over the long
connection the instance dials out to, so nothing needs a public callback
URL, a platform allowlist, or a signature check.

`core/src/host/feishu-inbound.js` is where the policy lives:

- **Off by default.** `feishu.enabled` must be `true`; while it is off
no socket is dialled at all. An inbound socket listens for traffic, so
turning it on is a decision, not an upgrade side effect.
- **The owner is the only sender that starts a turn.** An unset or empty
`feishu.ownerOpenId` normalises to `undefined` and refuses everyone; an
empty `open_id` never compares equal to it. Anyone else is one log line
and no turn — a stranger's message must not spend the owner's tokens.
- **A replay lands once, and only a delivered event is remembered.** A
bounded ring of 512 `event_id`s, dropped oldest first: a reconnecting
socket replays a tail, and the ring is what makes that one turn. The id
enters the ring only after `deliver` returned, so a handover that threw
is retried instead of being refused as a duplicate for ever; an
`inFlight` set covers two copies arriving while the first is still being
handed over. It is memory only — no state write, no secret.
- **One socket per connector.** A second `start()` while the first dials
shares it; a dial that failed is not remembered, so the next start tries
again.
- **App credentials are one credential.** `FEISHU_APP_ID` +
`FEISHU_APP_SECRET` are read through the same `rt.secretValue` phase 1
uses; either half missing or blank answers `undefined`, so a half-saved
app dials nothing instead of failing at the handshake. Neither value
leaves the function.
- **The row says what happened.** `connectorsView()`'s Feishu entry
grows `inbound: { state, lastEventAt, lastError, app }` —
`off|connecting|ready|error`, `app` is `set` or `missing`, and the error
is `rt.redactSecrets`-ed and cut to 300 characters. No value ever
reaches the view.
- **One path in, one path out.** A message lands through
`remote-chat.deliver({ handle: 'feishu:<chat_id>', text, name, plugin:
'feishu' })` — the seam a friend's Bot already uses, which fences the
text as outside input. The answer leaves through the same seam:
`connectors.js` registers a sender for the `feishu` plugin
(`remote-chat.onSend`) the first time a message lands, and it posts on
the `FEISHU_WEBHOOK` phase 1 already uses. Nothing new was added to
groups or delivery.

`readEvent()` accepts only the `im.message.receive_v1` text envelope:
`header.event_id`, `sender.sender_id.open_id`, `message.{chat_id,
message_type, content}`, where `content` is a JSON *string*. A v1 shape,
a card callback, an image or an unparseable body reads as null, logs one
line and starts nothing — a connector that invents a message out of an
unknown body is worse than one that says it does not know it.

The client commit on top of that seam:

- **`core/src/host/feishu-sdk.js` is the default adapter** over
`@larksuiteoapi/node-sdk`. The package is imported with `await
import(...)` **inside `connect()`**, never as a top-level static import,
so `feishu-inbound`'s tests — and a host that never turns the long
connection on — run without it installed; a missing package is one
readable line naming it. The app pair is read at dial time, the SDK's
`onReady`/`onError` decide when `connect()` resolves or rejects (a
socket that never opens gives up at a 15 s deadline), and `close()`
hangs the client up once. The SDK flattens a v2 event before a handler
sees it (`event_id` beside `message`); the adapter rebuilds Feishu's own
`{ header, event }` envelope rather than teaching the policy a second
shape.
- **`rt.feishuTransport` is read on every `start()`**, not copied while
the modules install. `connectors.js` puts the SDK adapter on the runtime
before the policy reads it, and passes a getter (`transport: () =>
rt.feishuTransport`) instead of the value, so an adapter installed after
boot — a plugin's client, a test's stub — is the one the next **Try
again** dials. With the snapshot the row would stay at `error: no
long-connection transport is installed` forever while every injected
test passed.
- **`stop()` no longer loses the socket it cancels.** A generation
counter is bumped by `stop()`; a dial already in flight compares its
generation on return and, if it lost, hangs up the socket that arrived
instead of adopting it. Without that, a stop landing before `connect`
resolved left `closer` empty (socket alive) and then wrote `state:
'ready'` and `connected += 1` behind the owner's back.
- **The boot dial waits for the secrets load**, so an app pair that is
saved is not read as missing, and the phase-1 row pins in
`feishu.test.js` now spell out the `inbound` field they gained.
- **`@larksuiteoapi/node-sdk@1.76.0`** (exact, no caret) is `core/`'s
first runtime dependency: `package-lock.json` was regenerated with `npm
install --package-lock-only --no-fund --no-audit`, and `ds-bot-patches
generate` put the hunk in `0007-version-and-tooling.patch` — no patch
hunk was written by hand and `files/` gained no mirror for these two
files.

## The INC at `02e3ed67` (Reviewer, comment 6414433193)

Two code defects, both fixed in `0946f4c1` — the 15 inbound tests could
not see either, because they never leave the chat:

- **Blocker: an answer had no exit.** `remote-chat.forward` keys its
sender by `bot.remote.plugin`, and this module called `deliver` without
one, so the chat was opened with `remote.plugin === undefined` and every
answer read `Not sent: the plugin that reaches this Bot is not running.`
Switch `feishu.enabled` on, type in Feishu: the turn starts and its
answer goes nowhere. The chat now carries the `feishu` plugin, `open()`
refreshes a chat an older build opened without it, and the `feishu`
sender is registered on the seam when the first message lands
(`remote-chat` installs after `connectors`, so it cannot be registered
while module install runs). A missing webhook or a refusal is a `Not
sent:` line in the chat, never a silent drop.
- **Medium: a message whose delivery failed was lost for good.**
`handleEvent` wrote the `event_id` into the ring before `await deliver`,
so a throw left the id there and every replay read `duplicate`. The id
is remembered only after the handover returned, and one already in
flight is refused by an `inFlight` set while it waits, so two copies
arriving together still start one turn.
- The Low (a chat named `feishu:oc_…` and drawn by Settings as a
friend's Bot) is untouched and still listed under Risk as an open
question.

## Red without the source change

Seventeen knives at this tip. Each removes exactly one guard, runs one
test file, and the file is restored from the commit afterwards:

1. the id is never remembered after the handover → `not ok 1 - a
replayed event_id starts one turn, and the second one says duplicate`,
`not ok 2 - a ring this size still forgets the oldest id…`, `not ok 16 -
a message whose delivery throws is handed over again…` (14 pass)
2. the sender check dropped → `not ok 3 - a message from anyone but the
owner starts no turn` (16 pass)
3. the off switch ignored → `not ok 5 - switched off, it dials nothing
at all` (16 pass)
4. the raw connect error reported instead of the redacted one → `not ok
6 - a failed connect is one redacted line in the row…` and `not ok 12 -
the view never carries either half of the app pair` (15 pass)
5. an empty configured owner treated as an id → `not ok 4 - no owner and
no open_id never compare equal` (16 pass)
6. half an app pair accepted → `not ok 11 - the app pair is one
credential…` (16 pass)
7. the missing-transport branch dropped → `not ok 13 - with no socket
adapter installed it says so…` and `not ok 14 - an adapter installed
after the first start…` (15 pass)
8. `connectors.js` snapshots the adapter (`transport:
rt.feishuTransport`) → `not ok 16 - an adapter installed after the
connectors module…`, and the two new seam tests below fail to reach a
chat at all (30 pass)
9. the policy snapshots the adapter at construction instead of at each
start → `not ok 14 - an adapter installed after the first start is the
one the next start dials` (16 pass)
10. the generation check in `stop()`'s path removed → `not ok 15 - a
stop while the socket dials hangs up the one that arrives…` (16 pass)
11. the adapter hands the SDK's flattened event straight to the policy →
`not ok 1 - the SDK is imported at dial time…` (4 pass)
12. the adapter imports and dials with no app pair saved → `not ok 3 -
with no app pair saved nothing is imported and nothing dials` (4 pass)
13. the answer chat is opened without the `feishu` plugin → `not ok 17 -
a Feishu message opens its chat on the feishu plugin` (32 pass)
14. the reply sender is never registered on the seam → `not ok 18 - the
answer sender is registered on the chat seam when a Feishu message
arrives` (32 pass)
15. the reply sender reports success without posting → `not ok 19 - the
answer to a Feishu message leaves on the webhook phase 1 already posts
to` (32 pass)
16. the `event_id` is remembered before the handover → `not ok 16 - a
message whose delivery throws is handed over again, not remembered as
handled` (16 pass)
17. the in-flight guard dropped, so only the finished ring is consulted
→ `not ok 17 - two copies of one event arriving together still start one
turn` (16 pass)

Knives 8–10 are the two defects the first tip shipped green (a snapshot
transport, a stop racing a dial); 13–17 are the two the Reviewer found
at `02e3ed67` (no reply sender; the ring written before the handover,
and a repeat copy while one handover is in flight). Each now has a test
that fails only on that defect.

## Verification at the tip

Tip SHA: `0946f4c13a87ba36df58fdd3381ee2e487450bc3`

Ran:
- `test/feishu-inbound.test.js` **17/17**, `test/feishu-sdk.test.js`
**5/5**, `test/feishu.test.js` **8/8**, `test/connectors.test.js`
**33/33**, `test/connector-access.test.js` **19/19**,
`test/remote-chat.test.js` **18/18**,
`test/friend-chat-awaiting.test.js` **7/7** — 107/107 across the seven
files this diff touches.
- The whole `core` suite (`test/*.test.js --test-concurrency=1`): **1709
tests, 1704 pass, 5 fail**. The five are the known base-red ones,
unchanged by this branch: `check-arch-docs`'s `empty diff passes` (no
diffable `HEAD` in this checkout), `composer-label` / `header-effects` /
`read-page` (`jsdom` not installed here), and `usage-fields`'s `fixture
usage.sqlite has one real calls row with all five fields`.
`src/client/model-test.js` is outside that glob and also red here for a
missing `react`.
- `node scripts/ds-bot-patches.mjs check` — three lines, the pin line
real because this checkout has git: `ok ds-bot/ =
Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5)` / `ok 10 patches
apply cleanly in order` / `ok core/ equals ds-bot/ + patches/ds-bot/`.
- `cd core && node scripts/check-arch-docs.mjs` → `arch-docs: OK (20
changed file(s))`.
- `oxlint -c .oxlintrc.json src test` → `Found 58 warnings and 0
errors.` (all pre-existing).
- `.github/scripts/changelog.mjs check` → `11 fragment(s) … are well
formed`; `check-empty-catch.mjs` exit 0; `check-mojibake.mjs` → 0
occurrences of U+FFFD in 1100 text files; `testing-layers.mjs` → 411
test files in 16 workflows, every one classified;
`core/scripts/build-client.mjs --check` exit 0 (no shared change, so
`client.js` is untouched).

Not run (and why):
- **A real Feishu socket.** No app exists for this instance yet, and
every test injects a stub adapter, so no test touches the network. The
owner has to create a Feishu app with the bot ability and
`im.message.receive_v1` subscribed over the long connection, then save
`FEISHU_APP_ID` / `FEISHU_APP_SECRET` through `secret-set`; both values
stay out of `state.json` and out of the view.
- `npm ci` locally: `core/node_modules` in this checkout is a shared
symlink, so a `ci` would have deleted it. The lock is in sync — `npm
install --package-lock-only` wrote it and a second run wrote no change —
and `flybot-tests` runs `npm ci` in `core/`, which is where the install
is proved.
- `plugins/*`, `interop`, `live-e2e`: this diff's only shared file is
`connectors.js` and its tests, both run above.

## Risk

- **The answer goes out on the phase-1 webhook.** `FEISHU_WEBHOOK` posts
to the chat that webhook belongs to, so the reply lands there, not in
whatever conversation the app bot was messaged in. With the app bot and
the webhook in the same group that is the same chat; otherwise the
answer is visible but in the webhook's chat. Sending back into the exact
`chat_id` would need the app's send scope on top of the receive scope,
which is why it is not done here.
- **The SDK's own dependencies float.** `@larksuiteoapi/node-sdk@1.76.0`
pins its own 7 direct dependencies with caret ranges, so two consumers
installing at different times can get different trees; we can only lock
the tree our `package-lock.json` records.
- **`core/` gains its first runtime dependency**: one entry in
`package.json`, about 40 packages installed. `check-pack` and the
workflows are unchanged — the tests action already runs `npm ci` in
`core/` and caches on `core/package-lock.json`.
- **Settings still draws a Feishu chat the Bot Social way.**
`remote-chat.deliver` carries `SOURCE_KIND`/`REMOTE_FORM` from Bot
Social, so a Feishu conversation appears as "a friend's Bot" in
Settings, and with no profile name the chat is named `feishu:oc_…`. That
is a display decision, not a side effect to settle inside a transport,
so it is untouched here and listed as an open question.
- **The transport is trusted.** Whatever implements `connect(onEvent)`
must hand over exactly the envelopes Feishu sent; `readEvent` narrows
what it accepts, but a client that invents a message can still start a
turn.
- **Inbound is off by default and owner-only**, so the exposure this
adds is one outbound socket that only the owner can use; a stranger's
message in a group the app can read is refused and logged, never turned
into a turn.

## Files in this diff (20)

| file | change |
| --- | --- |
| `core/src/host/feishu-inbound.js` | new, +210 — policy: gate, ring
after the handover, credential pair, lazy transport, stop generation,
view |
| `core/src/host/feishu-sdk.js` | new, +80 — the SDK adapter: lazy
import, envelope rebuild, ready/error, one close |
| `core/src/host/connectors.js` | +79/−1 — default adapter, getter,
restart on Try again, `inbound` in the row, the `feishu` reply sender |
| `core/package.json` | +3 — `"@larksuiteoapi/node-sdk": "1.76.0"` |
| `core/package-lock.json` | +605 — regenerated with
`--package-lock-only` |
| `core/test/feishu-inbound.test.js` | new, +265 — 17 tests |
| `core/test/feishu-sdk.test.js` | new, +91 — 5 tests |
| `core/test/connectors.test.js` | +100/−2 — the row assertion, the
late-adapter case, the reply seam |
| `core/test/feishu.test.js` | +5/−2 — the phase-1 row pins now carry
`inbound` |
| `core/test/remote-chat.test.js` | +22 — a reply typed into a `feishu:`
chat leaves on the webhook |
| `docs/subsystems/core-host.md` | +2 — the inbound paragraph next to
the Feishu sender paragraph |
| `changelog.d/1266-feishu-inbound.added.md` | new, +1 |
| `patches/ds-bot/0001-core-fixes.patch` | regenerated for
`connectors.js`, `feishu-inbound.js` and the tests |
| `patches/ds-bot/0007-version-and-tooling.patch` | regenerated for
`package.json` + `package-lock.json` |
| `patches/ds-bot/files/src/host/feishu-inbound.js` | generated mirror |
| `patches/ds-bot/files/src/host/feishu-sdk.js` | generated mirror |
| `patches/ds-bot/files/test/feishu-inbound.test.js` | generated mirror
|
| `patches/ds-bot/files/test/feishu-sdk.test.js` | generated mirror |
| `patches/ds-bot/files/test/feishu.test.js` | generated mirror |
| `patches/ds-bot/files/test/remote-chat.test.js` | generated mirror |

## Checkpoint

<!-- flybot:checkpoint -->
```json
{
  "pr": 1278,
  "claim": "Dev",
  "issue": 1266,
  "summary": "Feishu phase 2 inbound, both ways: a message the owner types in Feishu starts a turn here over the long connection, and the answer leaves on the FEISHU_WEBHOOK phase 1 already posts with. feishu-inbound.js keeps the policy — off unless enabled, owner-only, a 512 event_id ring written only after a successful handover, one socket, the app pair as one credential — and feishu-sdk.js is the default SDK adapter, so core/ gains its first runtime dependency. The INC at 02e3ed6 found the chat opened without the feishu plugin and the ring written before deliver; both are fixed, with seventeen knives.",
  "files": ["core/src/host/feishu-inbound.js", "core/src/host/feishu-sdk.js", "core/src/host/connectors.js", "core/package.json", "core/package-lock.json", "core/test/feishu-inbound.test.js", "core/test/feishu-sdk.test.js", "core/test/connectors.test.js", "core/test/remote-chat.test.js", "docs/subsystems/core-host.md", "patches/ds-bot/0001-core-fixes.patch", "changelog.d/1266-feishu-inbound.added.md"],
  "checks": { "ran": "feishu-inbound 17/17, connectors 33/33, remote-chat 18/18, other four touched files 39/39 (107); full core 1704/1709 (5 known base-red); ds-bot-patches 3 lines ok; arch-docs OK (20); oxlint 0 errors; 17 knives red", "result": "green", "at": "0946f4c13a87ba36df58fdd3381ee2e487450bc3" },
  "next": "none — review"
}
```
<!-- /flybot:checkpoint -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feishu connector, phase 1: this instance posts into one Feishu group

2 participants