Repository navigation
feat(connectors): Feishu sender through a custom-bot webhook (#1263) - #1269
Conversation
… 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.
|
Written-by: Dev-2 PASS request at What to read:
Tier: not T1 — new host surface plus a rebuilt client bundle, so this needs the verdict. |
|
Verdict: PASS at a3bc41e Judged on the tree at this tip (fork head). One body-form note first: the body has no What I checked
Knives, all three re-cut here
Each was restored and checked by RanRan: from the tip tree ( 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
|
|
| 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 与 basecore/(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。
|
核对对象: 1. 逐条落点:只有 1 个 source 段变了
2. 四个 patch 各自覆盖的源文件段(base
本 PR 另有 3 个 core 侧文件走 3.
4. 两侧逐行相等(7 个文件 × 两侧 = 14/14) 按旧侧起点把每个 hunk 套回 5. pin 行是 6. 承载性判别:剔掉这一行后第三行会红 把 结论: |
) (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 -->
feat(connectors): Feishu sender through a custom-bot webhook (#1263)
Fixes #1263
Owner: Dev2
Reviewer: Reviewer
Written-by: Dev-2
Triage graded this
agent-readyand setOwner: Dev2(comment6411621477); 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.jslearns a second shape next to the mount. A definition withkind: 'sender'gets a status and one POST — nothing is mounted, no Bot's tool list changes, andconnector-access.jskeeps its two ids (a sender has no tools, so a per-Bot switch would change nothing).FEISHU_WEBHOOKfirst, then proved with one test message. A refusal stays on the row asCould not connect: …with Try again beside it (the same shape a failed MCP mount has), because the URL is already a saved secret andredactSecretscan mask it. The URL must be https on the configuredfeishu.apiorigin: it is the credential, so it is never posted to another host.readywithout messaging the group;connector-retry("Try again") is the one path that proves it again.connector-send { id, text }(new, inchannel.jsandSHARED_BLOCKED) posts one message on demand and recordslastOkAt/lastErrorinstate.connectors.feishu. Nothing posts room traffic by itself.code: 0success,9499rate limit,19021refused signature), sofeishuPostmaps the body intonotify.js's vocabulary and the policy staysnotify.js'screateNotifier(3 attempts, backoff,retry-afterhonoured, 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.withDeadlineraces the fetch, so a fetch that ignores its abort signal cannot hang either.src/client/connectors.js: the row's input isFeishu 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.connectedConnectorsdrops senders, so no Bot settings card offers a switch for it.core/client.jsrebuilt withnpm run build;core/src/client/i18n.jshas the zh entries.Red first
core/test/feishu.test.js, 8 cases, all red onmain(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: 9499must be attempts=2sent;code: 19021must be attempts=1failed, 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, fromstate.jsonand from every argument of everyrt.warncall (that last one is where a pasted-and-not-yet-saved secret would leak).Knife, on this tip, each reversion restored afterwards:
9499classed as permanentnot ok 5 - a rate limit in the body is retried, a refused signature is notkind: 'sender'removed from the definitionnot ok 1, 2, 3, 6not ok 7 - the Feishu row asks for a webhook URL and links to Feishu, not to GitHubTrade-offs, deliberate
$DSH_SECRET_FEISHU_WEBHOOK. Disconnect clears it.readyuntil 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.tenant_access_token+im/v1/messages) slot in behind the samepost()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/scripts508/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.jsbuild check green; lint 0 errors.Not run: the smoke test (no change to
index.jsstartup wiring; the new endpoint is reached through the sameapi/botroute 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."}