From a6723fa4a69e92793888776127e3ee1c56820c64 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Mon, 26 Oct 2026 18:35:50 +0800 Subject: [PATCH 1/3] feat(connectors): a Feishu message starts a turn here (#1266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feishu phase 1 only posted out. Phase 2 is the other direction: the instance dials Feishu over the long connection and a message the owner types there starts a turn here. The socket is injected (rt.feishuTransport), so the client behind it is one adapter and the dependency decision changes nothing else. feishu-inbound.js owns the policy: off unless feishu.enabled is true, the owner is the only sender that may start a turn, a bounded ring of 512 event_ids makes a replay land once, and App ID + App Secret are read as one credential. A message lands through remote-chat.deliver — the seam a friend's Bot already uses — and the reply leaves through the Feishu sender, so there is no second path into or out of a chat. The Settings row grows inbound: off|connecting|ready|error, the last event, the last error, and whether the app pair is set or missing (never a value). --- core/src/host/connectors.js | 23 +++ core/src/host/feishu-inbound.js | 172 +++++++++++++++++ core/test/connectors.test.js | 4 + core/test/feishu-inbound.test.js | 180 ++++++++++++++++++ docs/subsystems/core-host.md | 2 + patches/ds-bot/0001-core-fixes.patch | 49 +++-- .../ds-bot/files/src/host/feishu-inbound.js | 172 +++++++++++++++++ .../ds-bot/files/test/feishu-inbound.test.js | 180 ++++++++++++++++++ 8 files changed, 771 insertions(+), 11 deletions(-) create mode 100644 core/src/host/feishu-inbound.js create mode 100644 core/test/feishu-inbound.test.js create mode 100644 patches/ds-bot/files/src/host/feishu-inbound.js create mode 100644 patches/ds-bot/files/test/feishu-inbound.test.js diff --git a/core/src/host/connectors.js b/core/src/host/connectors.js index bdfc219c..4654b8ce 100644 --- a/core/src/host/connectors.js +++ b/core/src/host/connectors.js @@ -18,6 +18,7 @@ import { randomUUID } from 'node:crypto' import { createNotifier } from './notify.js' +import { appCredentials, createInbound } from './feishu-inbound.js' export const GITHUB = 'github' export const GITHUB_SECRET = 'GITHUB_TOKEN' @@ -414,6 +415,24 @@ export function install(rt) { } }) + // Feishu inbound (#1266): a message in Feishu starts a turn here. The socket is injected — + // `rt.feishuTransport` is the one adapter the SDK-versus-hand-written decision changes, so nothing + // below depends on which client dials. Off unless the owner turned it on, and with no socket adapter + // it starts nothing rather than pretend. The message lands through `remote-chat.deliver` — the same + // seam a friend's Bot uses — and the reply comes back through the sender above (`onSend`), so there + // is no second path into or out of a chat. + const feishuInbound = createInbound({ + enabled: rt.config?.feishu?.enabled === true, + ownerOpenId: rt.config?.feishu?.ownerOpenId, + transport: rt.feishuTransport, + deliver: async ({ handle, text, name }) => { + if (typeof rt.remoteChats?.deliver !== 'function') throw new Error('no chat seam is installed for a Feishu message') + await rt.remoteChats.deliver({ handle, text, name }) + }, + log: (format, ...args) => rt.warn(`ds-bot: ${format}`, ...args), + redact: rt.redactSecrets, + }) + const saved = c => state.connectors?.[c.def.id] const unmount = async (c) => { const fiber = c.mount?.fiber @@ -537,6 +556,7 @@ export function install(rt) { ...(c.def.kind !== undefined ? { kind: c.def.kind } : {}), account: rt.secretValue(c.def.secret) !== undefined ? c.def.account(saved(c)) : null, ...(c.def.extra !== undefined ? c.def.extra(rt.secretValue(c.def.secret) !== undefined ? saved(c) : undefined) : {}), + ...(c.def.id === FEISHU ? { inbound: { ...feishuInbound.view(), app: appCredentials(name => rt.secretValue(name)) === undefined ? 'missing' : 'set' } } : {}), }) const connectorsView = () => connectors.filter(c => c.options !== null).map(view) @@ -705,11 +725,14 @@ export function install(rt) { await rt.save() } + void feishuInbound.start() + ctx.effect(() => { stopped = false void scrubAddresses().catch(error => rt.warn('ds-bot: connectors: %s', reason(error))).then(() => rt.loadSecrets()).then(syncAll) return () => { stopped = true + void feishuInbound.stop() for (const c of connectors) void unmount(c) } }, 'ds-bot: connectors') diff --git a/core/src/host/feishu-inbound.js b/core/src/host/feishu-inbound.js new file mode 100644 index 00000000..2a3ecd9f --- /dev/null +++ b/core/src/host/feishu-inbound.js @@ -0,0 +1,172 @@ +// Feishu inbound over the long connection, first half (#1266): a message the owner types in Feishu +// starts a turn here. The socket itself is not in this module. Everything that decides is: the +// default-off switch, the owner gate, the bounded `event_id` ring, and the connection state the +// Settings row reads — all of it over an injected transport, so the dependency question (the +// official SDK versus a hand-written protobuf client) changes one adapter and nothing else. +// +// Two things this module deliberately does not decide: +// - how a Feishu conversation is *drawn* here. Phase 2 lands the message through +// `remote-chat.deliver` (`core/src/host/remote-chat.js`), whose `SOURCE_KIND`/`REMOTE_FORM` are +// Bot Social's shape; a Feishu chat rendered as "a friend's Bot" is a Settings question that is +// open, not something to settle inside a transport. +// - how the socket reconnects. A transport that drops hands the same `event_id` in again, and the +// ring is what makes that land once; speaking twice is the transport's send policy, not ours. +// +// The message from Feishu is untrusted text from outside the team, and `deliver` already fences it. + +// A reconnect replays a tail of recent events. This is how far back that tail may reach: a small +// ring, dropped oldest first, never a growing set and never persisted with a secret in it. +export const DEDUPE_RING = 512 +// `off` before the owner turns it on, `connecting` while the socket dials, `ready` once open, and +// `error` with one redacted line: the four values phase 1's row already uses for a connector. +export const STATES = ['off', 'connecting', 'ready', 'error'] + +// Feishu's event envelope: `header.event_id` and `header.event_type`, then `event.sender.sender_id.open_id` +// and `event.message.{chat_id, message_type, content}`, where `content` is a JSON *string*. +// Anything else — a v1 shape, a card callback, an edited message — reads as null and is logged, not +// guessed at: a connector that invents a message out of an unknown body is worse than one that says +// it does not know it. +export function readEvent(envelope) { + const header = envelope?.header + const message = envelope?.event?.message + const sender = envelope?.event?.sender?.sender_id + if (typeof header?.event_id !== 'string' || header.event_id === '') return null + if (typeof message?.chat_id !== 'string' || message.chat_id === '') return null + if (typeof sender?.open_id !== 'string') return null + if (message.message_type !== 'text') return null + let text + try { + text = JSON.parse(String(message.content ?? '')).text + } catch { + return null + } + if (typeof text !== 'string' || text.trim() === '') return null + // A text message mentions people as `@_user_1` placeholders; the handle map that resolves them is + // not something the owner's own 1:1 chat needs, so the placeholders are dropped rather than shown. + return { id: header.event_id, openId: sender.open_id, chatId: message.chat_id, text: text.replace(/@_user_\d+\s*/g, '').trim() } +} + +// Phase 1 spent one secret per connector (`FEISHU_WEBHOOK`, redacted by secrets.js). The long +// connection needs an app, which is a pair: this reads both through the same `rt.secretValue` and +// answers undefined unless both are present, so a half-configured app dials nothing instead of +// failing at the handshake. The two values never leave this function: the view reports `missing`, +// never the id or the secret. +export const APP_ID_SECRET = 'FEISHU_APP_ID' +export const APP_SECRET_SECRET = 'FEISHU_APP_SECRET' + +export function appCredentials(secretValue) { + const id = secretValue?.(APP_ID_SECRET) + const secret = secretValue?.(APP_SECRET_SECRET) + const asText = value => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined) + const appId = asText(id) + const appSecret = asText(secret) + if (appId === undefined || appSecret === undefined) return undefined + return { appId, appSecret } +} + +export const handleOf = chatId => `feishu:${chatId}` + +// `transport` is the only piece the dependency decision can change: +// { connect(onEvent) -> Promise<{ close(): Promise|void }>, nameOf(openId) -> Promise } +// `deliver({ handle, text, name })` is `remote-chat`'s seam, and `redact` is `rt.redactSecrets`. +export function createInbound({ + enabled = false, + ownerOpenId, + transport, + deliver, + log = () => {}, + redact = text => text, + now = () => Date.now(), + ring = DEDUPE_RING, +} = {}) { + const seen = new Set() + const order = [] + const view = { state: enabled ? 'connecting' : 'off', connected: 0, duplicates: 0, refused: 0, ignored: 0 } + let closer + let starting + + const remember = (id) => { + if (seen.has(id)) return false + seen.add(id) + order.push(id) + if (order.length > ring) seen.delete(order.shift()) + return true + } + + // The owner is the only sender that may start a turn. An unset or empty id refuses everyone: a + // missing owner and a missing `open_id` must never compare equal. + const owner = () => (typeof ownerOpenId === 'string' && ownerOpenId !== '' ? ownerOpenId : undefined) + + async function nameOf(openId) { + if (typeof transport?.nameOf !== 'function') return undefined + try { + const name = await transport.nameOf(openId) + return typeof name === 'string' && name.trim() !== '' ? name.trim() : undefined + } catch { + return undefined + } + } + + async function handleEvent(envelope) { + const read = readEvent(envelope) + if (read === null) { + view.ignored += 1 + log('a Feishu event carried no readable text message; no turn started') + return 'ignored' + } + const expected = owner() + if (expected === undefined || read.openId !== expected) { + view.refused += 1 + log('a Feishu message from %s is not the owner; no turn started', read.openId) + return 'refused' + } + if (!remember(read.id)) { + view.duplicates += 1 + log('a Feishu event %s was already handled; duplicate ignored', read.id) + return 'duplicate' + } + const name = (await nameOf(read.openId)) ?? handleOf(read.chatId) + await deliver({ handle: handleOf(read.chatId), text: read.text, name }) + view.lastEventAt = new Date(now()).toISOString() + return 'delivered' + } + + // One socket per connector. A second start while the first dials shares it, so a reconnect loop + // cannot become two members of the same conversation. + function start() { + if (!enabled) return Promise.resolve('off') + if (starting !== undefined) return starting + starting = (async () => { + if (typeof transport?.connect !== 'function') { + view.state = 'error' + view.error = 'no long-connection transport is installed' + log('the Feishu long connection has no transport: %s', view.error) + return 'error' + } + try { + closer = await transport.connect(handleEvent) + view.state = 'ready' + view.connected += 1 + delete view.error + return 'ready' + } catch (error) { + view.state = 'error' + view.error = redact([error, error?.cause].filter(Boolean).map(part => String(part?.message ?? part)).join(': ') || 'connect failed').slice(0, 300) + log('the Feishu long connection failed: %s', view.error) + return 'error' + } + })() + return starting + } + + async function stop() { + starting = undefined + const closing = closer + closer = undefined + view.state = enabled ? 'connecting' : 'off' + if (closing !== undefined) await Promise.resolve().then(() => closing.close()).catch(() => { /* an already-dead socket is not an error */ }) + } + + const viewOf = () => ({ state: view.state, ...(view.error === undefined ? {} : { error: view.error }), ...(view.lastEventAt === undefined ? {} : { lastEventAt: view.lastEventAt }), connected: view.connected, duplicates: view.duplicates, refused: view.refused }) + return { start, stop, handleEvent, view: viewOf, seen: () => seen.size } +} diff --git a/core/test/connectors.test.js b/core/test/connectors.test.js index b256ca55..ca0ea1a0 100644 --- a/core/test/connectors.test.js +++ b/core/test/connectors.test.js @@ -416,6 +416,10 @@ test('deleting COMPOSIO_API_KEY on the Secrets page unmounts Composio, and GitHu assert.equal(mounts[1].disposed, true) assert.equal(mounts[0].disposed, false) assert.deepEqual(view.map(entry => [entry.id, entry.state]), [['github', 'ready'], ['composio', 'off'], ['feishu', 'off']]) + // #1266: the Feishu row carries the inbound state as well, and with no app pair saved it says so + // without ever showing a value. Nothing dialled: the long connection is off by default. + assert.deepEqual(view.find(entry => entry.id === 'feishu').inbound, { state: 'off', connected: 0, duplicates: 0, refused: 0, app: 'missing' }) + }) test('a Composio address without https refuses to send the key', async (t) => { diff --git a/core/test/feishu-inbound.test.js b/core/test/feishu-inbound.test.js new file mode 100644 index 00000000..a2da93dc --- /dev/null +++ b/core/test/feishu-inbound.test.js @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { APP_ID_SECRET, APP_SECRET_SECRET, DEDUPE_RING, appCredentials, createInbound, readEvent } from '../src/host/feishu-inbound.js' + +// #1266 phase 2: a message in Feishu starts a turn here, over the long connection. The socket is not +// part of this module — every test injects one — so what is asserted is the policy a DSH upgrade or a +// stranger's message would break: one turn per event, the owner only, nothing dialled while off. + +const OWNER = 'ou_owner_1' + +const event = ({ id = 'e1', openId = OWNER, chatId = 'oc_chat_1', text = 'hello there', type = 'text', content } = {}) => ({ + header: { event_id: id, event_type: 'im.message.receive_v1' }, + event: { + sender: { sender_id: { open_id: openId } }, + message: { chat_id: chatId, message_type: type, content: content ?? JSON.stringify({ text }) }, + }, +}) + +function setup(options = {}) { + // `'ownerOpenId' in options` matters: an explicit `undefined` is the case "the owner was never + // configured", which must not fall back to the default the other tests use. + const { enabled = true, connect, nameOf } = options + const ownerOpenId = 'ownerOpenId' in options ? options.ownerOpenId : OWNER + const turns = [] + const logs = [] + const dials = [] + const transport = { + connect: connect ?? (async (onEvent) => { dials.push(onEvent); return { close: async () => {} } }), + nameOf: nameOf ?? (async () => 'Owner'), + } + const inbound = createInbound({ + enabled, + ownerOpenId, + transport, + deliver: async message => { turns.push(message) }, + log: (...args) => logs.push(args.map(String).join(' ')), + redact: text => String(text).split('app-secret').join('[redacted]'), + now: () => Date.parse('2026-10-26T10:00:00Z'), + }) + return { inbound, turns, logs, dials } +} + +test('a replayed event_id starts one turn, and the second one says duplicate (#1266)', async () => { + const { inbound, turns, logs } = setup() + await inbound.start() + assert.equal(await inbound.handleEvent(event()), 'delivered') + assert.equal(await inbound.handleEvent(event()), 'duplicate') + assert.equal(turns.length, 1) + assert.equal(turns[0].handle, 'feishu:oc_chat_1') + assert.equal(turns[0].text, 'hello there') + assert.match(logs.join('\n'), /duplicate/) + assert.equal(inbound.view().duplicates, 1) +}) + +test('a ring this size still forgets the oldest id, so a fresh event is not refused (#1266)', async () => { + const { inbound, turns } = setup() + await inbound.start() + await inbound.handleEvent(event({ id: 'ring-oldest' })) + for (let n = 0; n < DEDUPE_RING; n += 1) await inbound.handleEvent(event({ id: `filler-${n}` })) + assert.equal(turns.length, DEDUPE_RING + 1) + assert.equal(inbound.seen(), DEDUPE_RING) + // The oldest id left the ring, so a replay of it lands again: bounded memory is the priced choice. + assert.equal(await inbound.handleEvent(event({ id: 'ring-oldest' })), 'delivered') + assert.equal(turns.length, DEDUPE_RING + 2) +}) + +test('a message from anyone but the owner starts no turn (#1266)', async () => { + const { inbound, turns, logs } = setup() + await inbound.start() + assert.equal(await inbound.handleEvent(event({ openId: 'ou_someone_else' })), 'refused') + assert.equal(turns.length, 0) + assert.match(logs.join('\n'), /not the owner/) + assert.equal(inbound.view().refused, 1) +}) + +test('no owner and no open_id never compare equal (#1266)', async () => { + const unset = setup({ ownerOpenId: undefined }) + await unset.inbound.start() + assert.notEqual(await unset.inbound.handleEvent(event()), 'delivered') + assert.equal(unset.turns.length, 0) + const blank = setup({ ownerOpenId: '' }) + await blank.inbound.start() + assert.notEqual(await blank.inbound.handleEvent(event({ openId: '' })), 'delivered') + assert.equal(blank.turns.length, 0) +}) + +test('switched off, it dials nothing at all (#1266)', async () => { + const { inbound, dials } = setup({ enabled: false }) + assert.equal(await inbound.start(), 'off') + assert.equal(dials.length, 0) + assert.equal(inbound.view().state, 'off') +}) + +test('a failed connect is one redacted line in the row, not a thrown start (#1266)', async () => { + const { inbound } = setup({ + connect: async () => { throw new Error('handshake refused: app-secret=app-secret') }, + }) + assert.equal(await inbound.start(), 'error') + const view = inbound.view() + assert.equal(view.state, 'error') + assert.match(view.error, /handshake refused/) + assert.doesNotMatch(view.error, /app-secret/) + assert.match(view.error, /\[redacted\]/) +}) + +test('one socket per connector: a second start while it dials shares the first (#1266)', async () => { + const { inbound, dials } = setup() + const [a, b] = await Promise.all([inbound.start(), inbound.start()]) + assert.equal(a, 'ready') + assert.equal(b, 'ready') + assert.equal(dials.length, 1) + assert.equal(inbound.view().connected, 1) +}) + +test('an event that is not a text message is logged and starts nothing (#1266)', async () => { + const { inbound, turns, logs } = setup() + await inbound.start() + assert.equal(await inbound.handleEvent(event({ type: 'image' })), 'ignored') + assert.equal(await inbound.handleEvent(event({ content: 'not json' })), 'ignored') + assert.equal(await inbound.handleEvent({ header: { event_id: 'e9' } }), 'ignored') + assert.equal(turns.length, 0) + assert.match(logs.join('\n'), /no readable text message/) +}) + +test('a text message drops the mention placeholders and names the chat after the conversation (#1266)', async () => { + const { inbound, turns } = setup({ nameOf: async () => ' Someone ' }) + await inbound.start() + await inbound.handleEvent(event({ text: '@_user_1 ping the desk' })) + assert.equal(turns[0].text, 'ping the desk') + assert.equal(turns[0].name, 'Someone') + const unnamed = setup({ nameOf: async () => { throw new Error('no profile read') } }) + await unnamed.inbound.start() + await unnamed.inbound.handleEvent(event({ chatId: 'oc_named_later' })) + assert.equal(unnamed.turns[0].name, 'feishu:oc_named_later') +}) + +test('readEvent reads only the envelope phase 2 means to accept (#1266)', () => { + assert.deepEqual(readEvent(event({ id: 'e5', chatId: 'oc_5', text: 'a line' })), { id: 'e5', openId: OWNER, chatId: 'oc_5', text: 'a line' }) + assert.equal(readEvent({}), null) + assert.equal(readEvent(event({ content: '{}' })), null) + assert.equal(readEvent(event({ text: ' ' })), null) + assert.equal(readEvent({ header: { event_id: 'e6' }, event: { sender: { sender_id: { open_id: OWNER } }, message: { chat_id: 'oc', message_type: 'text', content: '{"text":"x"}' } } }) !== null, true) +}) + +test('the app pair is one credential: either half missing dials nothing (#1266)', () => { + const secrets = { [APP_ID_SECRET]: 'cli_app_1', [APP_SECRET_SECRET]: 'app-secret-value' } + const read = name => secrets[name] + assert.deepEqual(appCredentials(read), { appId: 'cli_app_1', appSecret: 'app-secret-value' }) + assert.equal(appCredentials(name => (name === APP_ID_SECRET ? undefined : secrets[name])), undefined) + assert.equal(appCredentials(name => (name === APP_SECRET_SECRET ? ' ' : secrets[name])), undefined) + assert.equal(appCredentials(undefined), undefined) +}) + +test('the view never carries either half of the app pair (#1266)', async () => { + const secrets = { [APP_ID_SECRET]: 'cli_app_1', [APP_SECRET_SECRET]: 'app-secret-value' } + const seen = [] + const transport = { + connect: async () => { throw new Error(`handshake refused for ${secrets[APP_ID_SECRET]} / ${secrets[APP_SECRET_SECRET]}`) }, + } + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport, + deliver: async message => { seen.push(message) }, + redact: text => String(text).split(secrets[APP_SECRET_SECRET]).join('[redacted]').split(secrets[APP_ID_SECRET]).join('[redacted]'), + }) + await inbound.start() + const view = JSON.stringify(inbound.view()) + assert.match(view, /handshake refused/) + assert.doesNotMatch(view, /app-secret-value/) + assert.doesNotMatch(view, /cli_app_1/) + assert.equal(seen.length, 0) +}) + +test('with no socket adapter installed it says so instead of pretending (#1266)', async () => { + const inbound = createInbound({ enabled: true, ownerOpenId: OWNER, deliver: async () => {} }) + assert.equal(await inbound.start(), 'error') + assert.deepEqual(inbound.view().error, 'no long-connection transport is installed') + assert.equal(inbound.view().connected, 0) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 44cfb75a..1aabe2e5 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -337,6 +337,8 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi Feishu (Lark) is the first **sender** connector (#1263): a group's custom-bot webhook is one HTTPS POST into the instance's own group, so nothing is mounted, no Bot's tool list changes, and `connector-access.js`'s `CONNECTOR_IDS` does not list it. Its definition carries `kind: 'sender'`, which the view reports and the client reads instead of hard-coding an id. A sender stops at the status: a saved webhook reads `ready` without posting, because the test POST belongs to the **connect** step, so a restart never messages the group; `connector-retry` ("Try again") is the one path that proves it again. The webhook is `FEISHU_WEBHOOK`, saved through `secret-set` **before** the probe — so a refusal stays on the row as `error` with the reason and `redactSecrets` can mask it, the way a failed mount does for GitHub — and it must be https on the configured `feishu.api` origin, else `A Feishu webhook must be https on `: the URL is the credential, so it may never be posted to another host and never appears in `state.json`, the view or a warn line. `connector-send { id, text }` posts one message on demand and records `lastOkAt` / `lastError` in `state.connectors.feishu`; nothing posts room traffic by itself. Feishu answers HTTP 200 with the verdict in the body (`code: 0` success, `9499` a rate limit, `19021` a refused signature), so `feishuPost` maps the body into notify.js's vocabulary and the retry policy stays `notify.js`'s `createNotifier` (3 attempts, backoff, honour `retry-after`, structural errors not retried) rather than a second one written here; `connector-send` uses a fresh idempotency key per call, so two messages with the same text are two messages. The connect probe and a send both carry a deadline (15 s and 60 s) and `withDeadline` races the fetch, so a fetch that ignores its abort signal cannot hang either. `feishu: false` hides the row; `connector-send` is in `SHARED_BLOCKED` like the other connector writes. +The same row carries a Feishu message coming **in** (#1266): `inbound` reports `off|connecting|ready|error`, the last event, the last error and whether the app pair is `set` or `missing` — never a value. `src/host/feishu-inbound.js` owns the policy: off unless `feishu.enabled` is true (an off connector dials nothing), the owner is the only sender that may start a turn (an unset or empty `feishu.ownerOpenId` refuses everyone, and an empty `open_id` never compares equal), a bounded ring of 512 `event_id`s makes a replay land once, and one socket per connector shares a start. The socket itself is injected as `rt.feishuTransport` (`connect(onEvent) -> { close }`) so the client behind it is one adapter; the message lands through `remote-chat.deliver`, the seam a friend's Bot already uses, and the reply leaves through the sender above — there is no second path into or out of a chat. `FEISHU_APP_ID` + `FEISHU_APP_SECRET` are read as one credential through the same `rt.secretValue`, and a half-saved pair reports `missing` and dials nothing. + ### Secret scope defaults (#545) A key the owner saves is for **all of the owner's Bots** (`scope: 'all'`) unless the owner narrows it: a card's "Only ", the Secrets page's *Chosen Bots* or *No Bot*. That holds for a connector key too (`GITHUB_TOKEN`, `COMPOSIO_API_KEY`): `connector-connect` no longer passes `scope: []`, and a reconnect keeps the scope the key already has. A scope the owner chose is stored with `scopeSet: true` (`secret-update`, `secret-set` with a `scope`, a card's "Only ", and a deleted Bot leaving a picked list), so an empty list stays "No Bot". On load, an empty list **without** `scopeSet` is the old connector default and becomes `'all'` once (written back by the host that holds the team; a reading host widens it in memory only). An empty list the owner picked before #545 carries no marker and is widened too: there is no way to tell it apart. **Every turn of every one of the owner's own Bots** gets the keys its scope allows (owner decision 2026-10-22, #601: 「放开这个权限管理,我的bot都应该能看到」). Where the turn came from no longer matters for secrets: a group round, a Bot-to-Bot relay, a scheduled turn, a turn a friend started on a shared Bot, a turn with no recorded origin, and bash inside a sub-agent all get them. A sub-agent's shell resolves to its parent Bot along the same chain as the connector guard (`rt.whoseAgent`, from `connector-access.js`); a chain that cannot be followed to a Bot gets no value. The boundaries that stay: a friend's Bot (`remote`) never gets the owner's keys, and a key scoped to chosen Bots goes only to those Bots. A capability (`canUseSecret`) follows the same rule. This replaces the turn-origin rule of #545/#581, under which a shell got `'all'` keys only on a turn that `settled` as the owner's. The connector *tools* follow the same rule (#601, below); secrets no longer read `turn-origin.js`. Owner decision 2026-10-20 (#359, AGENTS.md hard rule 4). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index fa3a3270..42c05e02 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -2601,7 +2601,7 @@ diff --git a/src/host/chat-index.js b/src/host/chat-index.js diff --git a/src/host/connectors.js b/src/host/connectors.js --- a/src/host/connectors.js +++ b/src/host/connectors.js -@@ -1,27 +1,55 @@ +@@ -1,27 +1,56 @@ -// Connectors: outside services every Bot can use. GitHub is the first one. Its tools come -// from GitHub's MCP server through DSH's own MCP client, mounted on the plugin context, -// so every Bot and Session gets them as mcp__github__. @@ -2631,6 +2631,7 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + +import { randomUUID } from 'node:crypto' +import { createNotifier } from './notify.js' ++import { appCredentials, createInbound } from './feishu-inbound.js' export const GITHUB = 'github' export const GITHUB_SECRET = 'GITHUB_TOKEN' @@ -2669,7 +2670,7 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js let response try { response = await request(`${api.replace(/\/$/, '')}/user`, { -@@ -38,132 +38,653 @@ export async function signIn(token, { api = DEFAULTS.api, fetch: request = fetch +@@ -38,132 +38,675 @@ export async function signIn(token, { api = DEFAULTS.api, fetch: request = fetch return { login: user.login } } @@ -3031,14 +3032,31 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + } + }) + ++ // Feishu inbound (#1266): a message in Feishu starts a turn here. The socket is injected — ++ // `rt.feishuTransport` is the one adapter the SDK-versus-hand-written decision changes, so nothing ++ // below depends on which client dials. Off unless the owner turned it on, and with no socket adapter ++ // it starts nothing rather than pretend. The message lands through `remote-chat.deliver` — the same ++ // seam a friend's Bot uses — and the reply comes back through the sender above (`onSend`), so there ++ // is no second path into or out of a chat. ++ const feishuInbound = createInbound({ ++ enabled: rt.config?.feishu?.enabled === true, ++ ownerOpenId: rt.config?.feishu?.ownerOpenId, ++ transport: rt.feishuTransport, ++ deliver: async ({ handle, text, name }) => { ++ if (typeof rt.remoteChats?.deliver !== 'function') throw new Error('no chat seam is installed for a Feishu message') ++ await rt.remoteChats.deliver({ handle, text, name }) ++ }, ++ log: (format, ...args) => rt.warn(`ds-bot: ${format}`, ...args), ++ redact: rt.redactSecrets, ++ }) ++ + const saved = c => state.connectors?.[c.def.id] + const unmount = async (c) => { + const fiber = c.mount?.fiber + c.mount = null + if (fiber !== undefined) await Promise.resolve().then(() => fiber.dispose()).catch(() => { /* a fiber that fails to dispose must not block unloading */ }) - } - -- // The MCP client wraps the server's answer (a 401, say) as the cause. ++ } ++ + // A sender's retry policy is notify.js's, not a second one written here: one transport per + // sender, reading the saved webhook when it is called, so a reconnect needs no rebuild. + const notifiers = new Map(connectors.filter(c => c.def.kind === 'sender').map(c => [c.def.id, createNotifier({ @@ -3064,8 +3082,9 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + c.status = { state: 'error', error: reason(error) } + rt.warn('ds-bot: %s connector failed: %s', c.def.name, c.status.error) + } -+ } -+ + } + +- // The MCP client wraps the server's answer (a 401, say) as the cause. + // The MCP client wraps the server's answer (a 401, say) as the cause. The session address of a + // mount in progress is not kept anywhere, so it is also taken out of what is shown or logged. + let addresses = [] @@ -3188,6 +3207,7 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + ...(c.def.kind !== undefined ? { kind: c.def.kind } : {}), + account: rt.secretValue(c.def.secret) !== undefined ? c.def.account(saved(c)) : null, + ...(c.def.extra !== undefined ? c.def.extra(rt.secretValue(c.def.secret) !== undefined ? saved(c) : undefined) : {}), ++ ...(c.def.id === FEISHU ? { inbound: { ...feishuInbound.view(), app: appCredentials(name => rt.secretValue(name)) === undefined ? 'missing' : 'set' } } : {}), + }) + const connectorsView = () => connectors.filter(c => c.options !== null).map(view) @@ -3335,8 +3355,7 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js async 'connector-retry'(body) { - known(body.id) + const c = known(body.id) - await rt.loadSecrets() -- await sync(true) ++ await rt.loadSecrets() + await sync(c, true) + return connectorsView() + }, @@ -3348,7 +3367,8 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + const text = typeof body.text === 'string' ? body.text.trim() : '' + if (text === '') throw new Error('Type a message first') + if (c.insecure) throw new Error(INSECURE(c.def.name)) -+ await rt.loadSecrets() + await rt.loadSecrets() +- await sync(true) + if (rt.secretValue(c.def.secret) === undefined) throw new Error(`Connect ${c.def.name} first`) + const notifier = notifierOf(c) + // A fresh key per call: two messages with the same text are two messages, not a duplicate. @@ -3372,6 +3392,8 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + state.connectors[COMPOSIO] = { ...state.connectors[COMPOSIO], session: kept(session) } + await rt.save() + } ++ ++ void feishuInbound.start() + ctx.effect(() => { stopped = false @@ -3380,6 +3402,7 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js return () => { stopped = true - void unmount() ++ void feishuInbound.stop() + for (const c of connectors) void unmount(c) } }, 'ds-bot: connectors') @@ -6376,7 +6399,7 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js }) test('a GitHub address without https refuses to send the token', async (t) => { -@@ -165,10 +165,614 @@ test('a GitHub address without https refuses to send the token', async (t) => { +@@ -165,10 +165,618 @@ test('a GitHub address without https refuses to send the token', async (t) => { let asked = false globalThis.fetch = async () => { asked = true; return answer(200, { login: 'octo' }) } const { ctx, home, mounts } = setup({ github: { url: 'http://mcp.test/', api: 'https://gh.test' } }) @@ -6581,6 +6604,10 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js + assert.equal(mounts[1].disposed, true) + assert.equal(mounts[0].disposed, false) + assert.deepEqual(view.map(entry => [entry.id, entry.state]), [['github', 'ready'], ['composio', 'off'], ['feishu', 'off']]) ++ // #1266: the Feishu row carries the inbound state as well, and with no app pair saved it says so ++ // without ever showing a value. Nothing dialled: the long connection is off by default. ++ assert.deepEqual(view.find(entry => entry.id === 'feishu').inbound, { state: 'off', connected: 0, duplicates: 0, refused: 0, app: 'missing' }) ++ +}) + +test('a Composio address without https refuses to send the key', async (t) => { diff --git a/patches/ds-bot/files/src/host/feishu-inbound.js b/patches/ds-bot/files/src/host/feishu-inbound.js new file mode 100644 index 00000000..2a3ecd9f --- /dev/null +++ b/patches/ds-bot/files/src/host/feishu-inbound.js @@ -0,0 +1,172 @@ +// Feishu inbound over the long connection, first half (#1266): a message the owner types in Feishu +// starts a turn here. The socket itself is not in this module. Everything that decides is: the +// default-off switch, the owner gate, the bounded `event_id` ring, and the connection state the +// Settings row reads — all of it over an injected transport, so the dependency question (the +// official SDK versus a hand-written protobuf client) changes one adapter and nothing else. +// +// Two things this module deliberately does not decide: +// - how a Feishu conversation is *drawn* here. Phase 2 lands the message through +// `remote-chat.deliver` (`core/src/host/remote-chat.js`), whose `SOURCE_KIND`/`REMOTE_FORM` are +// Bot Social's shape; a Feishu chat rendered as "a friend's Bot" is a Settings question that is +// open, not something to settle inside a transport. +// - how the socket reconnects. A transport that drops hands the same `event_id` in again, and the +// ring is what makes that land once; speaking twice is the transport's send policy, not ours. +// +// The message from Feishu is untrusted text from outside the team, and `deliver` already fences it. + +// A reconnect replays a tail of recent events. This is how far back that tail may reach: a small +// ring, dropped oldest first, never a growing set and never persisted with a secret in it. +export const DEDUPE_RING = 512 +// `off` before the owner turns it on, `connecting` while the socket dials, `ready` once open, and +// `error` with one redacted line: the four values phase 1's row already uses for a connector. +export const STATES = ['off', 'connecting', 'ready', 'error'] + +// Feishu's event envelope: `header.event_id` and `header.event_type`, then `event.sender.sender_id.open_id` +// and `event.message.{chat_id, message_type, content}`, where `content` is a JSON *string*. +// Anything else — a v1 shape, a card callback, an edited message — reads as null and is logged, not +// guessed at: a connector that invents a message out of an unknown body is worse than one that says +// it does not know it. +export function readEvent(envelope) { + const header = envelope?.header + const message = envelope?.event?.message + const sender = envelope?.event?.sender?.sender_id + if (typeof header?.event_id !== 'string' || header.event_id === '') return null + if (typeof message?.chat_id !== 'string' || message.chat_id === '') return null + if (typeof sender?.open_id !== 'string') return null + if (message.message_type !== 'text') return null + let text + try { + text = JSON.parse(String(message.content ?? '')).text + } catch { + return null + } + if (typeof text !== 'string' || text.trim() === '') return null + // A text message mentions people as `@_user_1` placeholders; the handle map that resolves them is + // not something the owner's own 1:1 chat needs, so the placeholders are dropped rather than shown. + return { id: header.event_id, openId: sender.open_id, chatId: message.chat_id, text: text.replace(/@_user_\d+\s*/g, '').trim() } +} + +// Phase 1 spent one secret per connector (`FEISHU_WEBHOOK`, redacted by secrets.js). The long +// connection needs an app, which is a pair: this reads both through the same `rt.secretValue` and +// answers undefined unless both are present, so a half-configured app dials nothing instead of +// failing at the handshake. The two values never leave this function: the view reports `missing`, +// never the id or the secret. +export const APP_ID_SECRET = 'FEISHU_APP_ID' +export const APP_SECRET_SECRET = 'FEISHU_APP_SECRET' + +export function appCredentials(secretValue) { + const id = secretValue?.(APP_ID_SECRET) + const secret = secretValue?.(APP_SECRET_SECRET) + const asText = value => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined) + const appId = asText(id) + const appSecret = asText(secret) + if (appId === undefined || appSecret === undefined) return undefined + return { appId, appSecret } +} + +export const handleOf = chatId => `feishu:${chatId}` + +// `transport` is the only piece the dependency decision can change: +// { connect(onEvent) -> Promise<{ close(): Promise|void }>, nameOf(openId) -> Promise } +// `deliver({ handle, text, name })` is `remote-chat`'s seam, and `redact` is `rt.redactSecrets`. +export function createInbound({ + enabled = false, + ownerOpenId, + transport, + deliver, + log = () => {}, + redact = text => text, + now = () => Date.now(), + ring = DEDUPE_RING, +} = {}) { + const seen = new Set() + const order = [] + const view = { state: enabled ? 'connecting' : 'off', connected: 0, duplicates: 0, refused: 0, ignored: 0 } + let closer + let starting + + const remember = (id) => { + if (seen.has(id)) return false + seen.add(id) + order.push(id) + if (order.length > ring) seen.delete(order.shift()) + return true + } + + // The owner is the only sender that may start a turn. An unset or empty id refuses everyone: a + // missing owner and a missing `open_id` must never compare equal. + const owner = () => (typeof ownerOpenId === 'string' && ownerOpenId !== '' ? ownerOpenId : undefined) + + async function nameOf(openId) { + if (typeof transport?.nameOf !== 'function') return undefined + try { + const name = await transport.nameOf(openId) + return typeof name === 'string' && name.trim() !== '' ? name.trim() : undefined + } catch { + return undefined + } + } + + async function handleEvent(envelope) { + const read = readEvent(envelope) + if (read === null) { + view.ignored += 1 + log('a Feishu event carried no readable text message; no turn started') + return 'ignored' + } + const expected = owner() + if (expected === undefined || read.openId !== expected) { + view.refused += 1 + log('a Feishu message from %s is not the owner; no turn started', read.openId) + return 'refused' + } + if (!remember(read.id)) { + view.duplicates += 1 + log('a Feishu event %s was already handled; duplicate ignored', read.id) + return 'duplicate' + } + const name = (await nameOf(read.openId)) ?? handleOf(read.chatId) + await deliver({ handle: handleOf(read.chatId), text: read.text, name }) + view.lastEventAt = new Date(now()).toISOString() + return 'delivered' + } + + // One socket per connector. A second start while the first dials shares it, so a reconnect loop + // cannot become two members of the same conversation. + function start() { + if (!enabled) return Promise.resolve('off') + if (starting !== undefined) return starting + starting = (async () => { + if (typeof transport?.connect !== 'function') { + view.state = 'error' + view.error = 'no long-connection transport is installed' + log('the Feishu long connection has no transport: %s', view.error) + return 'error' + } + try { + closer = await transport.connect(handleEvent) + view.state = 'ready' + view.connected += 1 + delete view.error + return 'ready' + } catch (error) { + view.state = 'error' + view.error = redact([error, error?.cause].filter(Boolean).map(part => String(part?.message ?? part)).join(': ') || 'connect failed').slice(0, 300) + log('the Feishu long connection failed: %s', view.error) + return 'error' + } + })() + return starting + } + + async function stop() { + starting = undefined + const closing = closer + closer = undefined + view.state = enabled ? 'connecting' : 'off' + if (closing !== undefined) await Promise.resolve().then(() => closing.close()).catch(() => { /* an already-dead socket is not an error */ }) + } + + const viewOf = () => ({ state: view.state, ...(view.error === undefined ? {} : { error: view.error }), ...(view.lastEventAt === undefined ? {} : { lastEventAt: view.lastEventAt }), connected: view.connected, duplicates: view.duplicates, refused: view.refused }) + return { start, stop, handleEvent, view: viewOf, seen: () => seen.size } +} diff --git a/patches/ds-bot/files/test/feishu-inbound.test.js b/patches/ds-bot/files/test/feishu-inbound.test.js new file mode 100644 index 00000000..a2da93dc --- /dev/null +++ b/patches/ds-bot/files/test/feishu-inbound.test.js @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { APP_ID_SECRET, APP_SECRET_SECRET, DEDUPE_RING, appCredentials, createInbound, readEvent } from '../src/host/feishu-inbound.js' + +// #1266 phase 2: a message in Feishu starts a turn here, over the long connection. The socket is not +// part of this module — every test injects one — so what is asserted is the policy a DSH upgrade or a +// stranger's message would break: one turn per event, the owner only, nothing dialled while off. + +const OWNER = 'ou_owner_1' + +const event = ({ id = 'e1', openId = OWNER, chatId = 'oc_chat_1', text = 'hello there', type = 'text', content } = {}) => ({ + header: { event_id: id, event_type: 'im.message.receive_v1' }, + event: { + sender: { sender_id: { open_id: openId } }, + message: { chat_id: chatId, message_type: type, content: content ?? JSON.stringify({ text }) }, + }, +}) + +function setup(options = {}) { + // `'ownerOpenId' in options` matters: an explicit `undefined` is the case "the owner was never + // configured", which must not fall back to the default the other tests use. + const { enabled = true, connect, nameOf } = options + const ownerOpenId = 'ownerOpenId' in options ? options.ownerOpenId : OWNER + const turns = [] + const logs = [] + const dials = [] + const transport = { + connect: connect ?? (async (onEvent) => { dials.push(onEvent); return { close: async () => {} } }), + nameOf: nameOf ?? (async () => 'Owner'), + } + const inbound = createInbound({ + enabled, + ownerOpenId, + transport, + deliver: async message => { turns.push(message) }, + log: (...args) => logs.push(args.map(String).join(' ')), + redact: text => String(text).split('app-secret').join('[redacted]'), + now: () => Date.parse('2026-10-26T10:00:00Z'), + }) + return { inbound, turns, logs, dials } +} + +test('a replayed event_id starts one turn, and the second one says duplicate (#1266)', async () => { + const { inbound, turns, logs } = setup() + await inbound.start() + assert.equal(await inbound.handleEvent(event()), 'delivered') + assert.equal(await inbound.handleEvent(event()), 'duplicate') + assert.equal(turns.length, 1) + assert.equal(turns[0].handle, 'feishu:oc_chat_1') + assert.equal(turns[0].text, 'hello there') + assert.match(logs.join('\n'), /duplicate/) + assert.equal(inbound.view().duplicates, 1) +}) + +test('a ring this size still forgets the oldest id, so a fresh event is not refused (#1266)', async () => { + const { inbound, turns } = setup() + await inbound.start() + await inbound.handleEvent(event({ id: 'ring-oldest' })) + for (let n = 0; n < DEDUPE_RING; n += 1) await inbound.handleEvent(event({ id: `filler-${n}` })) + assert.equal(turns.length, DEDUPE_RING + 1) + assert.equal(inbound.seen(), DEDUPE_RING) + // The oldest id left the ring, so a replay of it lands again: bounded memory is the priced choice. + assert.equal(await inbound.handleEvent(event({ id: 'ring-oldest' })), 'delivered') + assert.equal(turns.length, DEDUPE_RING + 2) +}) + +test('a message from anyone but the owner starts no turn (#1266)', async () => { + const { inbound, turns, logs } = setup() + await inbound.start() + assert.equal(await inbound.handleEvent(event({ openId: 'ou_someone_else' })), 'refused') + assert.equal(turns.length, 0) + assert.match(logs.join('\n'), /not the owner/) + assert.equal(inbound.view().refused, 1) +}) + +test('no owner and no open_id never compare equal (#1266)', async () => { + const unset = setup({ ownerOpenId: undefined }) + await unset.inbound.start() + assert.notEqual(await unset.inbound.handleEvent(event()), 'delivered') + assert.equal(unset.turns.length, 0) + const blank = setup({ ownerOpenId: '' }) + await blank.inbound.start() + assert.notEqual(await blank.inbound.handleEvent(event({ openId: '' })), 'delivered') + assert.equal(blank.turns.length, 0) +}) + +test('switched off, it dials nothing at all (#1266)', async () => { + const { inbound, dials } = setup({ enabled: false }) + assert.equal(await inbound.start(), 'off') + assert.equal(dials.length, 0) + assert.equal(inbound.view().state, 'off') +}) + +test('a failed connect is one redacted line in the row, not a thrown start (#1266)', async () => { + const { inbound } = setup({ + connect: async () => { throw new Error('handshake refused: app-secret=app-secret') }, + }) + assert.equal(await inbound.start(), 'error') + const view = inbound.view() + assert.equal(view.state, 'error') + assert.match(view.error, /handshake refused/) + assert.doesNotMatch(view.error, /app-secret/) + assert.match(view.error, /\[redacted\]/) +}) + +test('one socket per connector: a second start while it dials shares the first (#1266)', async () => { + const { inbound, dials } = setup() + const [a, b] = await Promise.all([inbound.start(), inbound.start()]) + assert.equal(a, 'ready') + assert.equal(b, 'ready') + assert.equal(dials.length, 1) + assert.equal(inbound.view().connected, 1) +}) + +test('an event that is not a text message is logged and starts nothing (#1266)', async () => { + const { inbound, turns, logs } = setup() + await inbound.start() + assert.equal(await inbound.handleEvent(event({ type: 'image' })), 'ignored') + assert.equal(await inbound.handleEvent(event({ content: 'not json' })), 'ignored') + assert.equal(await inbound.handleEvent({ header: { event_id: 'e9' } }), 'ignored') + assert.equal(turns.length, 0) + assert.match(logs.join('\n'), /no readable text message/) +}) + +test('a text message drops the mention placeholders and names the chat after the conversation (#1266)', async () => { + const { inbound, turns } = setup({ nameOf: async () => ' Someone ' }) + await inbound.start() + await inbound.handleEvent(event({ text: '@_user_1 ping the desk' })) + assert.equal(turns[0].text, 'ping the desk') + assert.equal(turns[0].name, 'Someone') + const unnamed = setup({ nameOf: async () => { throw new Error('no profile read') } }) + await unnamed.inbound.start() + await unnamed.inbound.handleEvent(event({ chatId: 'oc_named_later' })) + assert.equal(unnamed.turns[0].name, 'feishu:oc_named_later') +}) + +test('readEvent reads only the envelope phase 2 means to accept (#1266)', () => { + assert.deepEqual(readEvent(event({ id: 'e5', chatId: 'oc_5', text: 'a line' })), { id: 'e5', openId: OWNER, chatId: 'oc_5', text: 'a line' }) + assert.equal(readEvent({}), null) + assert.equal(readEvent(event({ content: '{}' })), null) + assert.equal(readEvent(event({ text: ' ' })), null) + assert.equal(readEvent({ header: { event_id: 'e6' }, event: { sender: { sender_id: { open_id: OWNER } }, message: { chat_id: 'oc', message_type: 'text', content: '{"text":"x"}' } } }) !== null, true) +}) + +test('the app pair is one credential: either half missing dials nothing (#1266)', () => { + const secrets = { [APP_ID_SECRET]: 'cli_app_1', [APP_SECRET_SECRET]: 'app-secret-value' } + const read = name => secrets[name] + assert.deepEqual(appCredentials(read), { appId: 'cli_app_1', appSecret: 'app-secret-value' }) + assert.equal(appCredentials(name => (name === APP_ID_SECRET ? undefined : secrets[name])), undefined) + assert.equal(appCredentials(name => (name === APP_SECRET_SECRET ? ' ' : secrets[name])), undefined) + assert.equal(appCredentials(undefined), undefined) +}) + +test('the view never carries either half of the app pair (#1266)', async () => { + const secrets = { [APP_ID_SECRET]: 'cli_app_1', [APP_SECRET_SECRET]: 'app-secret-value' } + const seen = [] + const transport = { + connect: async () => { throw new Error(`handshake refused for ${secrets[APP_ID_SECRET]} / ${secrets[APP_SECRET_SECRET]}`) }, + } + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport, + deliver: async message => { seen.push(message) }, + redact: text => String(text).split(secrets[APP_SECRET_SECRET]).join('[redacted]').split(secrets[APP_ID_SECRET]).join('[redacted]'), + }) + await inbound.start() + const view = JSON.stringify(inbound.view()) + assert.match(view, /handshake refused/) + assert.doesNotMatch(view, /app-secret-value/) + assert.doesNotMatch(view, /cli_app_1/) + assert.equal(seen.length, 0) +}) + +test('with no socket adapter installed it says so instead of pretending (#1266)', async () => { + const inbound = createInbound({ enabled: true, ownerOpenId: OWNER, deliver: async () => {} }) + assert.equal(await inbound.start(), 'error') + assert.deepEqual(inbound.view().error, 'no long-connection transport is installed') + assert.equal(inbound.view().connected, 0) +}) From 02e3ed67002c07877838e05b44cccabfb25ffd9f Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Mon, 26 Oct 2026 18:50:09 +0800 Subject: [PATCH 2/3] feat(feishu): dial the long connection with the official SDK (#1266) - `src/host/feishu-sdk.js` is the default adapter: the package is imported only when a socket is dialled, the app pair is read at that moment, and the SDK's flattened v2 event is rebuilt into the envelope `readEvent` already reads. - `@larksuiteoapi/node-sdk@1.76.0` is `core/`'s first runtime dependency; the lock was regenerated with `npm install --package-lock-only` and the hunk is in `0007-version-and-tooling.patch`. - `rt.feishuTransport` is read on every `start()`, so an adapter that arrives late comes up on the row's Try again, and a stop that lands while a socket dials hangs up the socket that arrives instead of reporting the row ready. - `Fixes #1266`; a changelog fragment now that the chain is usable. --- changelog.d/1266-feishu-inbound.added.md | 1 + core/package-lock.json | 605 +++++++++++++++++ core/package.json | 3 + core/src/host/connectors.js | 27 +- core/src/host/feishu-inbound.js | 44 +- core/src/host/feishu-sdk.js | 80 +++ core/test/connectors.test.js | 31 +- core/test/feishu-inbound.test.js | 40 ++ core/test/feishu-sdk.test.js | 91 +++ core/test/feishu.test.js | 7 +- docs/subsystems/core-host.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 75 ++- patches/ds-bot/0007-version-and-tooling.patch | 632 +++++++++++++++++- .../ds-bot/files/src/host/feishu-inbound.js | 44 +- patches/ds-bot/files/src/host/feishu-sdk.js | 80 +++ .../ds-bot/files/test/feishu-inbound.test.js | 40 ++ patches/ds-bot/files/test/feishu-sdk.test.js | 91 +++ patches/ds-bot/files/test/feishu.test.js | 7 +- 18 files changed, 1858 insertions(+), 42 deletions(-) create mode 100644 changelog.d/1266-feishu-inbound.added.md create mode 100644 core/src/host/feishu-sdk.js create mode 100644 core/test/feishu-sdk.test.js create mode 100644 patches/ds-bot/files/src/host/feishu-sdk.js create mode 100644 patches/ds-bot/files/test/feishu-sdk.test.js diff --git a/changelog.d/1266-feishu-inbound.added.md b/changelog.d/1266-feishu-inbound.added.md new file mode 100644 index 00000000..996045b5 --- /dev/null +++ b/changelog.d/1266-feishu-inbound.added.md @@ -0,0 +1 @@ +Feishu (Lark) connector, phase 2: a message the owner types in Feishu can start a turn here over the long connection. The app pair `FEISHU_APP_ID` / `FEISHU_APP_SECRET` is saved through `secret-set` and stays out of `state.json` and the view; the connection is off until `feishu.enabled` is true, dials only from an app pair that is complete, and only the owner's messages start a turn. A replayed `event_id` lands once, the message arrives through the same chat seam a friend's Bot uses, and the reply leaves through the Feishu sender — so there is no second way into or out of a chat. ([#1266](https://github.com/ThinkFlowLab/FlyBot/issues/1266)) diff --git a/core/package-lock.json b/core/package-lock.json index fd6cfd26..bd5626f4 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -8,6 +8,9 @@ "name": "ds-bot", "version": "0.2.9", "license": "Apache-2.0", + "dependencies": { + "@larksuiteoapi/node-sdk": "1.76.0" + }, "devDependencies": { "esbuild": "0.28.2" }, @@ -460,6 +463,243 @@ "node": ">=18" } }, + "node_modules/@larksuiteoapi/node-sdk": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.76.0.tgz", + "integrity": "sha512-v/bxQNCugwAmMPbZtBGJtSu8hdQLiB1/29Jr+GBIG18DyQ4LYyp7gxQ74pkhXVSuoUeH4v+/ZHAw/SW+KMrj2g==", + "license": "MIT", + "dependencies": { + "axios": "^1.16.0", + "lodash.identity": "^3.0.0", + "lodash.merge": "^4.6.2", + "lodash.pickby": "^4.6.0", + "protobufjs": "^7.2.6", + "qs": "^6.14.2", + "ws": "^8.19.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -501,6 +741,371 @@ "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" } + }, + "node_modules/follow-redirects": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz", + "integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash.identity": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-3.0.0.tgz", + "integrity": "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/lodash.pickby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz", + "integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/core/package.json b/core/package.json index 2c4e23c8..414c81eb 100644 --- a/core/package.json +++ b/core/package.json @@ -59,6 +59,9 @@ "@deepseek-ai/dsh-tools": "*", "@deepseek-ai/dsh-web": "*" }, + "dependencies": { + "@larksuiteoapi/node-sdk": "1.76.0" + }, "dsh": { "client": { "platform": "web" diff --git a/core/src/host/connectors.js b/core/src/host/connectors.js index 4654b8ce..8bbdeab8 100644 --- a/core/src/host/connectors.js +++ b/core/src/host/connectors.js @@ -19,6 +19,7 @@ import { randomUUID } from 'node:crypto' import { createNotifier } from './notify.js' import { appCredentials, createInbound } from './feishu-inbound.js' +import { createFeishuTransport } from './feishu-sdk.js' export const GITHUB = 'github' export const GITHUB_SECRET = 'GITHUB_TOKEN' @@ -421,10 +422,21 @@ export function install(rt) { // it starts nothing rather than pretend. The message lands through `remote-chat.deliver` — the same // seam a friend's Bot uses — and the reply comes back through the sender above (`onSend`), so there // is no second path into or out of a chat. + // + // The default adapter is the official SDK, and it is put on the runtime *before* the policy reads + // it. That read happens on every `start()` rather than once here, so an adapter installed later + // (a test's stub, a plugin's own client) is the one the next start dials instead of a snapshot + // taken while it was missing. + if (rt.feishuTransport === undefined) { + rt.feishuTransport = createFeishuTransport({ + credentials: () => appCredentials(name => rt.secretValue(name)), + log: (format, ...args) => rt.warn(`ds-bot: ${format}`, ...args), + }) + } const feishuInbound = createInbound({ enabled: rt.config?.feishu?.enabled === true, ownerOpenId: rt.config?.feishu?.ownerOpenId, - transport: rt.feishuTransport, + transport: () => rt.feishuTransport, deliver: async ({ handle, text, name }) => { if (typeof rt.remoteChats?.deliver !== 'function') throw new Error('no chat seam is installed for a Feishu message') await rt.remoteChats.deliver({ handle, text, name }) @@ -690,6 +702,10 @@ export function install(rt) { const c = known(body.id) await rt.loadSecrets() await sync(c, true) + // "Try again" is one press for the whole row: a Feishu row proves the webhook and, when the + // long connection is switched on, dials it again — which is how an adapter installed after the + // first attempt comes up without a restart. + if (c.def.id === FEISHU) await feishuInbound.start() return connectorsView() }, // One message, on demand, once. Nothing posts room traffic on its own: that is a spam @@ -725,11 +741,14 @@ export function install(rt) { await rt.save() } - void feishuInbound.start() - ctx.effect(() => { stopped = false - void scrubAddresses().catch(error => rt.warn('ds-bot: connectors: %s', reason(error))).then(() => rt.loadSecrets()).then(syncAll) + // The long connection dials once the secrets are loaded: the app pair lives there, so starting + // before that read would report a missing app that is in fact saved. + void scrubAddresses().catch(error => rt.warn('ds-bot: connectors: %s', reason(error))).then(() => rt.loadSecrets()).then(async () => { + await syncAll() + if (!stopped) await feishuInbound.start() + }) return () => { stopped = true void feishuInbound.stop() diff --git a/core/src/host/feishu-inbound.js b/core/src/host/feishu-inbound.js index 2a3ecd9f..dae641e2 100644 --- a/core/src/host/feishu-inbound.js +++ b/core/src/host/feishu-inbound.js @@ -68,6 +68,9 @@ export const handleOf = chatId => `feishu:${chatId}` // `transport` is the only piece the dependency decision can change: // { connect(onEvent) -> Promise<{ close(): Promise|void }>, nameOf(openId) -> Promise } +// It may also be a function answering that object, in which case it is called again on every +// `start()`: an adapter that is installed after this module still comes up, and one replaced while +// the connection is off is the one the next start dials. // `deliver({ handle, text, name })` is `remote-chat`'s seam, and `redact` is `rt.redactSecrets`. export function createInbound({ enabled = false, @@ -84,6 +87,12 @@ export function createInbound({ const view = { state: enabled ? 'connecting' : 'off', connected: 0, duplicates: 0, refused: 0, ignored: 0 } let closer let starting + // Bumped by `stop()`: a dial that was already in flight when the owner stopped the connector is + // not the socket this module wants open any more, so the one that arrives is hung up instead of + // adopted, and the row does not flip back to `ready` behind the owner's back. + let generation = 0 + + const socketOf = () => (typeof transport === 'function' ? transport() : transport) const remember = (id) => { if (seen.has(id)) return false @@ -136,30 +145,47 @@ export function createInbound({ function start() { if (!enabled) return Promise.resolve('off') if (starting !== undefined) return starting - starting = (async () => { - if (typeof transport?.connect !== 'function') { - view.state = 'error' - view.error = 'no long-connection transport is installed' - log('the Feishu long connection has no transport: %s', view.error) - return 'error' - } + const socket = socketOf() + // No adapter yet is a configuration state, not a failed dial: nothing is remembered, so a later + // start — a person pressing Try again, once the adapter is there — still dials instead of reading + // the same error back forever. + if (typeof socket?.connect !== 'function') { + view.state = 'error' + view.error = 'no long-connection transport is installed' + log('the Feishu long connection has no transport: %s', view.error) + return Promise.resolve('error') + } + const mine = generation + const attempt = (async () => { try { - closer = await transport.connect(handleEvent) + const opened = await socket.connect(handleEvent) + if (mine !== generation) { + // `stop()` ran while this socket dialled. Hang up what arrived; report it neither ready nor + // as another connection. + await Promise.resolve().then(() => opened?.close?.()).catch(() => { /* an already-dead socket is not an error */ }) + return 'stopped' + } + closer = opened view.state = 'ready' view.connected += 1 delete view.error return 'ready' } catch (error) { + if (mine !== generation) return 'stopped' view.state = 'error' view.error = redact([error, error?.cause].filter(Boolean).map(part => String(part?.message ?? part)).join(': ') || 'connect failed').slice(0, 300) log('the Feishu long connection failed: %s', view.error) return 'error' } })() - return starting + starting = attempt + // A failed dial is not shared either: the next start tries again with whatever is installed then. + void attempt.then(result => { if (starting === attempt && result !== 'ready') starting = undefined }) + return attempt } async function stop() { + generation += 1 starting = undefined const closing = closer closer = undefined diff --git a/core/src/host/feishu-sdk.js b/core/src/host/feishu-sdk.js new file mode 100644 index 00000000..b9f53096 --- /dev/null +++ b/core/src/host/feishu-sdk.js @@ -0,0 +1,80 @@ +// The long-connection adapter over Feishu's own SDK (#1266). This file is the whole of the +// dependency decision: `feishu-inbound.js` keeps the policy and takes a transport, and this adapts +// the SDK's WebSocket client to that `connect(onEvent) -> { close }` shape. A hand-written protobuf +// client would replace this module and nothing else. +// +// The package is imported inside `connect`, never at the top: a host that never turns the long +// connection on, and every test that injects a stub socket, must run without it installed. +// +// `readEvent` reads Feishu's own `{ header, event }` envelope, but the SDK flattens a v2 event +// before it reaches a handler — `event_id` sits beside `message` — so the envelope is rebuilt here +// rather than teaching the policy layer a second shape. +export const SDK_PACKAGE = '@larksuiteoapi/node-sdk' +// A socket that never opens must not hang a start: the row says `error` instead. +export const READY_TIMEOUT_MS = 15_000 + +const within = (promise, ms, message) => { + let timer + const deadline = new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms) + }) + return Promise.race([promise, deadline]).finally(() => clearTimeout(timer)) +} + +export function createFeishuTransport({ + credentials, + importSdk = () => import(SDK_PACKAGE), + log = () => {}, + readyTimeoutMs = READY_TIMEOUT_MS, +} = {}) { + return { + // The app pair is read on every dial, not when the adapter is made: the owner saves it on the + // Secrets page at any time, and a pair that is not there refuses before any import or socket. + async connect(onEvent) { + const app = credentials?.() + if (app === undefined) throw new Error('A Feishu app id and app secret are needed before the long connection can dial') + let sdk + try { + sdk = await importSdk() + } catch (error) { + const why = String(error?.message ?? error).split('\n')[0] + throw new Error(`The Feishu long connection needs ${SDK_PACKAGE} installed: ${why}`) + } + const Lark = sdk?.default ?? sdk + const { WSClient, EventDispatcher } = Lark ?? {} + if (typeof WSClient !== 'function' || typeof EventDispatcher !== 'function') throw new Error(`${SDK_PACKAGE} does not export the long-connection client`) + + const dispatcher = new EventDispatcher({}).register({ + 'im.message.receive_v1': data => onEvent({ + header: { event_id: data?.event_id, event_type: 'im.message.receive_v1' }, + event: { sender: data?.sender, message: data?.message }, + }), + }) + let settle + const opened = new Promise((resolve, reject) => { settle = { resolve, reject } }) + const client = new WSClient({ + appId: app.appId, + appSecret: app.appSecret, + // `onReady` is the first open; a later reconnect is not a second inbox, so it only logs. + onReady: () => settle.resolve(), + onError: error => settle.reject(error), + onReconnecting: () => log('the Feishu long connection is reconnecting'), + }) + try { + await client.start({ eventDispatcher: dispatcher }) + await within(opened, readyTimeoutMs, 'the Feishu long connection did not open') + } catch (error) { + try { client.close({ force: true }) } catch { /* nothing was opened */ } + throw error + } + let closed = false + return { + close: () => { + if (closed) return + closed = true + try { client.close({ force: true }) } catch { /* an already-dead socket is not an error */ } + }, + } + }, + } +} diff --git a/core/test/connectors.test.js b/core/test/connectors.test.js index ca0ea1a0..759f147b 100644 --- a/core/test/connectors.test.js +++ b/core/test/connectors.test.js @@ -6,7 +6,7 @@ import { syncBuiltinESMExports } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' -import { apply } from '../index.js' +import { apply, createRuntime } from '../index.js' import { cleanApps, composioAppStatus, composioDeleteSession, composioSearchApps, composioSession, composioSessionAlive, composioSessionFind, signIn } from '../src/host/connectors.js' import { fakeContext } from './fake-ctx.js' @@ -422,6 +422,35 @@ test('deleting COMPOSIO_API_KEY on the Secrets page unmounts Composio, and GitHu }) +// #1266: the inbound socket is read from the runtime on every start, not copied while module install +// runs. This is the shape a plugin (or a test) has when it puts its own adapter on the runtime after +// the connectors module was installed: the next Try again has to dial that one. +test('an adapter installed after the connectors module is the one the next Try again dials (#1266)', async (t) => { + const home = teamHome() + const ctx = fakeContext() + capture(ctx) + const rt = createRuntime(ctx, { home, feishu: { enabled: true, ownerOpenId: 'ou_owner_1' } }) + t.after(async () => { await ctx.dispose(); await cleanHome(home) }) + + const inboundOf = async () => (await call(ctx, 'state', {})).value.connectors.find(entry => entry.id === 'feishu').inbound + // Boot dialled the default adapter, which refuses because no app pair is saved: the error proves a + // socket was attempted, and that it was not the stub installed below. + const until = Date.now() + 2000 + let boot = await inboundOf() + while (boot.error === undefined && Date.now() < until) { + await new Promise(resolve => setTimeout(resolve, 10)) + boot = await inboundOf() + } + assert.match(boot.error ?? '', /app id and app secret/, JSON.stringify(boot)) + + const dials = [] + rt.feishuTransport = { connect: async (onEvent) => { dials.push(onEvent); return { close: async () => {} } } } + const after = (await call(ctx, 'connector-retry', { id: 'feishu' })).value.find(entry => entry.id === 'feishu') + assert.equal(after.inbound.state, 'ready') + assert.equal(after.inbound.connected, 1) + assert.equal(dials.length, 1, 'the adapter installed late is the one that dialled') +}) + test('a Composio address without https refuses to send the key', async (t) => { const realFetch = globalThis.fetch t.after(() => { globalThis.fetch = realFetch }) diff --git a/core/test/feishu-inbound.test.js b/core/test/feishu-inbound.test.js index a2da93dc..30f2c5c7 100644 --- a/core/test/feishu-inbound.test.js +++ b/core/test/feishu-inbound.test.js @@ -178,3 +178,43 @@ test('with no socket adapter installed it says so instead of pretending (#1266)' assert.deepEqual(inbound.view().error, 'no long-connection transport is installed') assert.equal(inbound.view().connected, 0) }) + +test('an adapter installed after the first start is the one the next start dials (#1266)', async () => { + // The policy is made before the adapter exists — the shape a module that installs its socket later + // has. A missing adapter must not be remembered as the answer. + const transport = { current: undefined } + const dials = [] + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport: () => transport.current, + deliver: async () => {}, + }) + assert.equal(await inbound.start(), 'error') + assert.equal(inbound.view().error, 'no long-connection transport is installed') + transport.current = { connect: async onEvent => { dials.push(onEvent); return { close: async () => {} } } } + assert.equal(await inbound.start(), 'ready') + assert.equal(dials.length, 1, 'the adapter that arrived late is the one that dialled') + assert.equal(inbound.view().connected, 1) +}) + +test('a stop while the socket dials hangs up the one that arrives and never reports ready (#1266)', async () => { + let release + const dialling = new Promise(resolve => { release = resolve }) + const closed = [] + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport: { + connect: async () => { await dialling; return { close: async () => { closed.push(1) } } }, + }, + deliver: async () => {}, + }) + const started = inbound.start() + await inbound.stop() + release() + assert.equal(await started, 'stopped', 'the dial that outlived stop is not adopted') + assert.deepEqual(closed, [1], 'the socket that arrived after stop is hung up, not leaked') + assert.equal(inbound.view().state, 'connecting', 'the row does not flip back to ready behind the owner') + assert.equal(inbound.view().connected, 0) +}) diff --git a/core/test/feishu-sdk.test.js b/core/test/feishu-sdk.test.js new file mode 100644 index 00000000..4faa21c8 --- /dev/null +++ b/core/test/feishu-sdk.test.js @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { readEvent } from '../src/host/feishu-inbound.js' +import { SDK_PACKAGE, createFeishuTransport } from '../src/host/feishu-sdk.js' + +// #1266: the one adapter that needs Feishu's own SDK. What is asserted here is the seam, not the +// network: the package is imported only when a socket is dialled, the SDK's flattened v2 event is +// handed to the policy as Feishu's own envelope, and a socket that fails or never opens ends the +// start with a line the row can show. + +const APP = { appId: 'cli_0123456789abcdef', appSecret: 'app-secret-value' } + +// A stand-in for the package: it records what the adapter asked it to do. +function fakeSdk({ open = true } = {}) { + const made = [] + class EventDispatcher { + register(handles) { this.handles = handles; return this } + } + class WSClient { + constructor(params) { this.params = params; this.closed = []; made.push(this) } + start({ eventDispatcher }) { + this.handles = eventDispatcher.handles + if (open) this.params.onReady?.() + return Promise.resolve() + } + close(params) { this.closed.push(params ?? {}) } + } + return { made, sdk: { WSClient, EventDispatcher } } +} + +const doesNotImport = () => { throw new Error('the package must not be imported here') } + +test('the SDK is imported at dial time, and its flattened event reaches the policy as Feishu sends it (#1266)', async () => { + const { made, sdk } = fakeSdk() + let imported = 0 + const seen = [] + const transport = createFeishuTransport({ + credentials: () => APP, + importSdk: async () => { imported += 1; return sdk }, + }) + assert.equal(imported, 0, 'nothing is imported before a socket is dialled') + const closer = await transport.connect(envelope => { seen.push(envelope) }) + assert.equal(imported, 1) + assert.equal(made[0].params.appId, APP.appId) + assert.equal(made[0].params.appSecret, APP.appSecret) + + // The SDK merges `header` and `event` into one object (`event_id` beside `message`); the adapter + // rebuilds the envelope `readEvent` reads, so the policy keeps one shape. + await made[0].handles['im.message.receive_v1']({ + event_id: 'e1', + sender: { sender_id: { open_id: 'ou_owner' } }, + message: { chat_id: 'oc_1', message_type: 'text', content: '{"text":"hello there"}' }, + }) + assert.deepEqual(readEvent(seen[0]), { id: 'e1', openId: 'ou_owner', chatId: 'oc_1', text: 'hello there' }) + + closer.close() + assert.deepEqual(made[0].closed, [{ force: true }]) + closer.close() + assert.equal(made[0].closed.length, 1, 'closing twice hangs up once') +}) + +test('a package that is not installed is one readable line, not a stack (#1266)', async () => { + const transport = createFeishuTransport({ + credentials: () => APP, + importSdk: async () => { throw new Error(`Cannot find package '${SDK_PACKAGE}' imported from core`) }, + }) + await assert.rejects(transport.connect(async () => {}), /needs @larksuiteoapi\/node-sdk installed: Cannot find package/) +}) + +test('with no app pair saved nothing is imported and nothing dials (#1266)', async () => { + const transport = createFeishuTransport({ credentials: () => undefined, importSdk: doesNotImport }) + await assert.rejects(transport.connect(async () => {}), /app id and app secret are needed/) +}) + +test('a socket that reports an error ends the start with that error (#1266)', async () => { + class WSClient { + constructor(params) { this.params = params } + start() { this.params.onError(new Error('handshake refused')) } + close() {} + } + class EventDispatcher { register(handles) { this.handles = handles; return this } } + const transport = createFeishuTransport({ credentials: () => APP, importSdk: async () => ({ WSClient, EventDispatcher }) }) + await assert.rejects(transport.connect(async () => {}), /handshake refused/) +}) + +test('a socket that never opens is given up at the deadline instead of hanging the start (#1266)', async () => { + const { made, sdk } = fakeSdk({ open: false }) + const transport = createFeishuTransport({ credentials: () => APP, importSdk: async () => sdk, readyTimeoutMs: 10 }) + await assert.rejects(transport.connect(async () => {}), /did not open/) + assert.equal(made[0].closed.length, 1, 'the half-open client is hung up') +}) diff --git a/core/test/feishu.test.js b/core/test/feishu.test.js index f0755e2a..149f5322 100644 --- a/core/test/feishu.test.js +++ b/core/test/feishu.test.js @@ -61,6 +61,9 @@ async function call(ctx, endpoint, payload) { } const row = (view, id = 'feishu') => (view?.connectors ?? view ?? []).find(entry => entry.id === id) +// #1266: the same row now carries the inbound state (off, no socket, app pair not saved). It is +// spelled out here instead of spread, so a value leaking into the row cannot pass unnoticed. +const INBOUND_OFF = { state: 'off', connected: 0, duplicates: 0, refused: 0, app: 'missing' } const said = (warns, needle) => warns.filter(args => args.some(value => String(value).includes(needle))) // A fake Feishu that records each POST and answers from the script (the last answer repeats). @@ -78,7 +81,7 @@ test('#1263: Feishu is a sender row of its own, off until a webhook is pasted', const { ctx, home } = setup() t.after(async () => { await ctx.dispose(); cleanHome(home) }) const state = await call(ctx, 'state', {}) - assert.deepEqual(row(state.value), { id: 'feishu', name: 'Feishu', state: 'off', kind: 'sender', account: null }) + assert.deepEqual(row(state.value), { id: 'feishu', name: 'Feishu', state: 'off', kind: 'sender', account: null, inbound: INBOUND_OFF }) // A sender has no tools, so it is not a name a saved capability may take either way. assert.deepEqual(state.value.connectors.map(entry => entry.id), ['github', 'composio', 'feishu']) }) @@ -94,7 +97,7 @@ test('#1263: connecting posts one test message, turns ready, and keeps the webho const connected = await call(ctx, 'connector-connect', { id: 'feishu', token: ` ${webhook} ` }) assert.equal(connected.ok, true, JSON.stringify(connected)) - assert.deepEqual(row(connected.value), { id: 'feishu', name: 'Feishu', state: 'ready', kind: 'sender', account: null }) + assert.deepEqual(row(connected.value), { id: 'feishu', name: 'Feishu', state: 'ready', kind: 'sender', account: null, inbound: INBOUND_OFF }) assert.equal(feishu.posts.length, 1, 'one test POST on connect') assert.equal(feishu.posts[0].url, webhook) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 1aabe2e5..f6413d5b 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -337,7 +337,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi Feishu (Lark) is the first **sender** connector (#1263): a group's custom-bot webhook is one HTTPS POST into the instance's own group, so nothing is mounted, no Bot's tool list changes, and `connector-access.js`'s `CONNECTOR_IDS` does not list it. Its definition carries `kind: 'sender'`, which the view reports and the client reads instead of hard-coding an id. A sender stops at the status: a saved webhook reads `ready` without posting, because the test POST belongs to the **connect** step, so a restart never messages the group; `connector-retry` ("Try again") is the one path that proves it again. The webhook is `FEISHU_WEBHOOK`, saved through `secret-set` **before** the probe — so a refusal stays on the row as `error` with the reason and `redactSecrets` can mask it, the way a failed mount does for GitHub — and it must be https on the configured `feishu.api` origin, else `A Feishu webhook must be https on `: the URL is the credential, so it may never be posted to another host and never appears in `state.json`, the view or a warn line. `connector-send { id, text }` posts one message on demand and records `lastOkAt` / `lastError` in `state.connectors.feishu`; nothing posts room traffic by itself. Feishu answers HTTP 200 with the verdict in the body (`code: 0` success, `9499` a rate limit, `19021` a refused signature), so `feishuPost` maps the body into notify.js's vocabulary and the retry policy stays `notify.js`'s `createNotifier` (3 attempts, backoff, honour `retry-after`, structural errors not retried) rather than a second one written here; `connector-send` uses a fresh idempotency key per call, so two messages with the same text are two messages. The connect probe and a send both carry a deadline (15 s and 60 s) and `withDeadline` races the fetch, so a fetch that ignores its abort signal cannot hang either. `feishu: false` hides the row; `connector-send` is in `SHARED_BLOCKED` like the other connector writes. -The same row carries a Feishu message coming **in** (#1266): `inbound` reports `off|connecting|ready|error`, the last event, the last error and whether the app pair is `set` or `missing` — never a value. `src/host/feishu-inbound.js` owns the policy: off unless `feishu.enabled` is true (an off connector dials nothing), the owner is the only sender that may start a turn (an unset or empty `feishu.ownerOpenId` refuses everyone, and an empty `open_id` never compares equal), a bounded ring of 512 `event_id`s makes a replay land once, and one socket per connector shares a start. The socket itself is injected as `rt.feishuTransport` (`connect(onEvent) -> { close }`) so the client behind it is one adapter; the message lands through `remote-chat.deliver`, the seam a friend's Bot already uses, and the reply leaves through the sender above — there is no second path into or out of a chat. `FEISHU_APP_ID` + `FEISHU_APP_SECRET` are read as one credential through the same `rt.secretValue`, and a half-saved pair reports `missing` and dials nothing. +The same row carries a Feishu message coming **in** (#1266): `inbound` reports `off|connecting|ready|error`, the last event, the last error and whether the app pair is `set` or `missing` — never a value. `src/host/feishu-inbound.js` owns the policy: off unless `feishu.enabled` is true (an off connector dials nothing), the owner is the only sender that may start a turn (an unset or empty `feishu.ownerOpenId` refuses everyone, and an empty `open_id` never compares equal), a bounded ring of 512 `event_id`s makes a replay land once, and one socket per connector shares a start. The socket itself is injected as `rt.feishuTransport` (`connect(onEvent) -> { close }`) so the client behind it is one adapter, and it is read again on every start rather than copied while the modules install — an adapter that arrives later comes up on the row's **Try again**, which retries the sender and the socket together; a stop that lands while a socket dials hangs up the one that arrives instead of adopting it. `src/host/feishu-sdk.js` is the default adapter over the official `@larksuiteoapi/node-sdk`, imported only when a socket is dialled and reading the app pair at that moment, so a host that never turns the long connection on pays nothing for the package; the message lands through `remote-chat.deliver`, the seam a friend's Bot already uses, and the reply leaves through the sender above — there is no second path into or out of a chat. `FEISHU_APP_ID` + `FEISHU_APP_SECRET` are read as one credential through the same `rt.secretValue`, and a half-saved pair reports `missing` and dials nothing. ### Secret scope defaults (#545) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 42c05e02..3e3898ca 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -2601,7 +2601,7 @@ diff --git a/src/host/chat-index.js b/src/host/chat-index.js diff --git a/src/host/connectors.js b/src/host/connectors.js --- a/src/host/connectors.js +++ b/src/host/connectors.js -@@ -1,27 +1,56 @@ +@@ -1,27 +1,57 @@ -// Connectors: outside services every Bot can use. GitHub is the first one. Its tools come -// from GitHub's MCP server through DSH's own MCP client, mounted on the plugin context, -// so every Bot and Session gets them as mcp__github__. @@ -2632,6 +2632,7 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js +import { randomUUID } from 'node:crypto' +import { createNotifier } from './notify.js' +import { appCredentials, createInbound } from './feishu-inbound.js' ++import { createFeishuTransport } from './feishu-sdk.js' export const GITHUB = 'github' export const GITHUB_SECRET = 'GITHUB_TOKEN' @@ -2670,7 +2671,7 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js let response try { response = await request(`${api.replace(/\/$/, '')}/user`, { -@@ -38,132 +38,675 @@ export async function signIn(token, { api = DEFAULTS.api, fetch: request = fetch +@@ -38,132 +38,693 @@ export async function signIn(token, { api = DEFAULTS.api, fetch: request = fetch return { login: user.login } } @@ -3038,10 +3039,21 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + // it starts nothing rather than pretend. The message lands through `remote-chat.deliver` — the same + // seam a friend's Bot uses — and the reply comes back through the sender above (`onSend`), so there + // is no second path into or out of a chat. ++ // ++ // The default adapter is the official SDK, and it is put on the runtime *before* the policy reads ++ // it. That read happens on every `start()` rather than once here, so an adapter installed later ++ // (a test's stub, a plugin's own client) is the one the next start dials instead of a snapshot ++ // taken while it was missing. ++ if (rt.feishuTransport === undefined) { ++ rt.feishuTransport = createFeishuTransport({ ++ credentials: () => appCredentials(name => rt.secretValue(name)), ++ log: (format, ...args) => rt.warn(`ds-bot: ${format}`, ...args), ++ }) ++ } + const feishuInbound = createInbound({ + enabled: rt.config?.feishu?.enabled === true, + ownerOpenId: rt.config?.feishu?.ownerOpenId, -+ transport: rt.feishuTransport, ++ transport: () => rt.feishuTransport, + deliver: async ({ handle, text, name }) => { + if (typeof rt.remoteChats?.deliver !== 'function') throw new Error('no chat seam is installed for a Feishu message') + await rt.remoteChats.deliver({ handle, text, name }) @@ -3055,8 +3067,9 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + const fiber = c.mount?.fiber + c.mount = null + if (fiber !== undefined) await Promise.resolve().then(() => fiber.dispose()).catch(() => { /* a fiber that fails to dispose must not block unloading */ }) -+ } -+ + } + +- // The MCP client wraps the server's answer (a 401, say) as the cause. + // A sender's retry policy is notify.js's, not a second one written here: one transport per + // sender, reading the saved webhook when it is called, so a reconnect needs no rebuild. + const notifiers = new Map(connectors.filter(c => c.def.kind === 'sender').map(c => [c.def.id, createNotifier({ @@ -3082,9 +3095,8 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + c.status = { state: 'error', error: reason(error) } + rt.warn('ds-bot: %s connector failed: %s', c.def.name, c.status.error) + } - } - -- // The MCP client wraps the server's answer (a 401, say) as the cause. ++ } ++ + // The MCP client wraps the server's answer (a 401, say) as the cause. The session address of a + // mount in progress is not kept anywhere, so it is also taken out of what is shown or logged. + let addresses = [] @@ -3357,6 +3369,10 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + const c = known(body.id) + await rt.loadSecrets() + await sync(c, true) ++ // "Try again" is one press for the whole row: a Feishu row proves the webhook and, when the ++ // long connection is switched on, dials it again — which is how an adapter installed after the ++ // first attempt comes up without a restart. ++ if (c.def.id === FEISHU) await feishuInbound.start() + return connectorsView() + }, + // One message, on demand, once. Nothing posts room traffic on its own: that is a spam @@ -3392,13 +3408,16 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + state.connectors[COMPOSIO] = { ...state.connectors[COMPOSIO], session: kept(session) } + await rt.save() + } -+ -+ void feishuInbound.start() + ctx.effect(() => { stopped = false - void rt.loadSecrets().then(() => sync()) -+ void scrubAddresses().catch(error => rt.warn('ds-bot: connectors: %s', reason(error))).then(() => rt.loadSecrets()).then(syncAll) ++ // The long connection dials once the secrets are loaded: the app pair lives there, so starting ++ // before that read would report a missing app that is in fact saved. ++ void scrubAddresses().catch(error => rt.warn('ds-bot: connectors: %s', reason(error))).then(() => rt.loadSecrets()).then(async () => { ++ await syncAll() ++ if (!stopped) await feishuInbound.start() ++ }) return () => { stopped = true - void unmount() @@ -6220,8 +6239,9 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' - import { apply } from '../index.js' +-import { apply } from '../index.js' -import { signIn } from '../src/host/connectors.js' ++import { apply, createRuntime } from '../index.js' +import { cleanApps, composioAppStatus, composioDeleteSession, composioSearchApps, composioSession, composioSessionAlive, composioSessionFind, signIn } from '../src/host/connectors.js' import { fakeContext } from './fake-ctx.js' @@ -6399,7 +6419,7 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js }) test('a GitHub address without https refuses to send the token', async (t) => { -@@ -165,10 +165,618 @@ test('a GitHub address without https refuses to send the token', async (t) => { +@@ -165,10 +165,647 @@ test('a GitHub address without https refuses to send the token', async (t) => { let asked = false globalThis.fetch = async () => { asked = true; return answer(200, { login: 'octo' }) } const { ctx, home, mounts } = setup({ github: { url: 'http://mcp.test/', api: 'https://gh.test' } }) @@ -6610,6 +6630,35 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js + +}) + ++// #1266: the inbound socket is read from the runtime on every start, not copied while module install ++// runs. This is the shape a plugin (or a test) has when it puts its own adapter on the runtime after ++// the connectors module was installed: the next Try again has to dial that one. ++test('an adapter installed after the connectors module is the one the next Try again dials (#1266)', async (t) => { ++ const home = teamHome() ++ const ctx = fakeContext() ++ capture(ctx) ++ const rt = createRuntime(ctx, { home, feishu: { enabled: true, ownerOpenId: 'ou_owner_1' } }) ++ t.after(async () => { await ctx.dispose(); await cleanHome(home) }) ++ ++ const inboundOf = async () => (await call(ctx, 'state', {})).value.connectors.find(entry => entry.id === 'feishu').inbound ++ // Boot dialled the default adapter, which refuses because no app pair is saved: the error proves a ++ // socket was attempted, and that it was not the stub installed below. ++ const until = Date.now() + 2000 ++ let boot = await inboundOf() ++ while (boot.error === undefined && Date.now() < until) { ++ await new Promise(resolve => setTimeout(resolve, 10)) ++ boot = await inboundOf() ++ } ++ assert.match(boot.error ?? '', /app id and app secret/, JSON.stringify(boot)) ++ ++ const dials = [] ++ rt.feishuTransport = { connect: async (onEvent) => { dials.push(onEvent); return { close: async () => {} } } } ++ const after = (await call(ctx, 'connector-retry', { id: 'feishu' })).value.find(entry => entry.id === 'feishu') ++ assert.equal(after.inbound.state, 'ready') ++ assert.equal(after.inbound.connected, 1) ++ assert.equal(dials.length, 1, 'the adapter installed late is the one that dialled') ++}) ++ +test('a Composio address without https refuses to send the key', async (t) => { + const realFetch = globalThis.fetch + t.after(() => { globalThis.fetch = realFetch }) diff --git a/patches/ds-bot/0007-version-and-tooling.patch b/patches/ds-bot/0007-version-and-tooling.patch index c3cd3ad9..4ed7f874 100644 --- a/patches/ds-bot/0007-version-and-tooling.patch +++ b/patches/ds-bot/0007-version-and-tooling.patch @@ -52,7 +52,7 @@ diff --git a/dev/smoke.mjs b/dev/smoke.mjs diff --git a/package-lock.json b/package-lock.json --- a/package-lock.json +++ b/package-lock.json -@@ -1,12 +1,12 @@ +@@ -1,13 +1,16 @@ { "name": "ds-bot", - "version": "0.1.5", @@ -65,8 +65,628 @@ diff --git a/package-lock.json b/package-lock.json - "version": "0.1.5", + "version": "0.2.9", "license": "Apache-2.0", ++ "dependencies": { ++ "@larksuiteoapi/node-sdk": "1.76.0" ++ }, "devDependencies": { "esbuild": "0.28.2" + }, +@@ -460,6 +460,243 @@ + "node": ">=18" + } + }, ++ "node_modules/@larksuiteoapi/node-sdk": { ++ "version": "1.76.0", ++ "resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.76.0.tgz", ++ "integrity": "sha512-v/bxQNCugwAmMPbZtBGJtSu8hdQLiB1/29Jr+GBIG18DyQ4LYyp7gxQ74pkhXVSuoUeH4v+/ZHAw/SW+KMrj2g==", ++ "license": "MIT", ++ "dependencies": { ++ "axios": "^1.16.0", ++ "lodash.identity": "^3.0.0", ++ "lodash.merge": "^4.6.2", ++ "lodash.pickby": "^4.6.0", ++ "protobufjs": "^7.2.6", ++ "qs": "^6.14.2", ++ "ws": "^8.19.0" ++ } ++ }, ++ "node_modules/@protobufjs/aspromise": { ++ "version": "1.1.2", ++ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", ++ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", ++ "license": "BSD-3-Clause" ++ }, ++ "node_modules/@protobufjs/base64": { ++ "version": "1.1.2", ++ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", ++ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", ++ "license": "BSD-3-Clause" ++ }, ++ "node_modules/@protobufjs/codegen": { ++ "version": "2.0.5", ++ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", ++ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", ++ "license": "BSD-3-Clause" ++ }, ++ "node_modules/@protobufjs/eventemitter": { ++ "version": "1.1.1", ++ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", ++ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", ++ "license": "BSD-3-Clause" ++ }, ++ "node_modules/@protobufjs/fetch": { ++ "version": "1.1.1", ++ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", ++ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", ++ "license": "BSD-3-Clause", ++ "dependencies": { ++ "@protobufjs/aspromise": "^1.1.1" ++ } ++ }, ++ "node_modules/@protobufjs/float": { ++ "version": "1.0.2", ++ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", ++ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", ++ "license": "BSD-3-Clause" ++ }, ++ "node_modules/@protobufjs/path": { ++ "version": "1.1.2", ++ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", ++ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", ++ "license": "BSD-3-Clause" ++ }, ++ "node_modules/@protobufjs/pool": { ++ "version": "1.1.0", ++ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", ++ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", ++ "license": "BSD-3-Clause" ++ }, ++ "node_modules/@protobufjs/utf8": { ++ "version": "1.1.2", ++ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", ++ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", ++ "license": "BSD-3-Clause" ++ }, ++ "node_modules/@types/node": { ++ "version": "26.6.5", ++ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", ++ "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", ++ "license": "MIT", ++ "dependencies": { ++ "undici-types": "~8.9.0" ++ } ++ }, ++ "node_modules/agent-base": { ++ "version": "6.0.2", ++ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", ++ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", ++ "license": "MIT", ++ "dependencies": { ++ "debug": "4" ++ }, ++ "engines": { ++ "node": ">= 6.0.0" ++ } ++ }, ++ "node_modules/asynckit": { ++ "version": "0.4.0", ++ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", ++ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", ++ "license": "MIT" ++ }, ++ "node_modules/axios": { ++ "version": "1.20.0", ++ "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", ++ "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", ++ "license": "MIT", ++ "dependencies": { ++ "follow-redirects": "^1.16.0", ++ "form-data": "^4.0.6", ++ "https-proxy-agent": "^5.0.1", ++ "proxy-from-env": "^2.1.0" ++ } ++ }, ++ "node_modules/call-bind-apply-helpers": { ++ "version": "1.0.2", ++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", ++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", ++ "license": "MIT", ++ "dependencies": { ++ "es-errors": "^1.3.0", ++ "function-bind": "^1.1.2" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, ++ "node_modules/call-bound": { ++ "version": "1.0.4", ++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", ++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", ++ "license": "MIT", ++ "dependencies": { ++ "call-bind-apply-helpers": "^1.0.2", ++ "get-intrinsic": "^1.3.0" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/combined-stream": { ++ "version": "1.0.8", ++ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", ++ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", ++ "license": "MIT", ++ "dependencies": { ++ "delayed-stream": "~1.0.0" ++ }, ++ "engines": { ++ "node": ">= 0.8" ++ } ++ }, ++ "node_modules/debug": { ++ "version": "4.4.3", ++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", ++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", ++ "license": "MIT", ++ "dependencies": { ++ "ms": "^2.1.3" ++ }, ++ "engines": { ++ "node": ">=6.0" ++ }, ++ "peerDependenciesMeta": { ++ "supports-color": { ++ "optional": true ++ } ++ } ++ }, ++ "node_modules/delayed-stream": { ++ "version": "1.0.0", ++ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", ++ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", ++ "license": "MIT", ++ "engines": { ++ "node": ">=0.4.0" ++ } ++ }, ++ "node_modules/dunder-proto": { ++ "version": "1.0.1", ++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", ++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", ++ "license": "MIT", ++ "dependencies": { ++ "call-bind-apply-helpers": "^1.0.1", ++ "es-errors": "^1.3.0", ++ "gopd": "^1.2.0" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, ++ "node_modules/es-define-property": { ++ "version": "1.0.1", ++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", ++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", ++ "license": "MIT", ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, ++ "node_modules/es-errors": { ++ "version": "1.3.0", ++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", ++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", ++ "license": "MIT", ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, ++ "node_modules/es-object-atoms": { ++ "version": "1.1.2", ++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", ++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", ++ "license": "MIT", ++ "dependencies": { ++ "es-errors": "^1.3.0" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, ++ "node_modules/es-set-tostringtag": { ++ "version": "2.1.0", ++ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", ++ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", ++ "license": "MIT", ++ "dependencies": { ++ "es-errors": "^1.3.0", ++ "get-intrinsic": "^1.2.6", ++ "has-tostringtag": "^1.0.2", ++ "hasown": "^2.0.2" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", +@@ -501,6 +501,371 @@ + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } ++ }, ++ "node_modules/follow-redirects": { ++ "version": "1.16.2", ++ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz", ++ "integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==", ++ "funding": [ ++ { ++ "type": "individual", ++ "url": "https://github.com/sponsors/RubenVerborgh" ++ } ++ ], ++ "license": "MIT", ++ "engines": { ++ "node": ">=4.0" ++ }, ++ "peerDependenciesMeta": { ++ "debug": { ++ "optional": true ++ } ++ } ++ }, ++ "node_modules/form-data": { ++ "version": "4.0.6", ++ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", ++ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", ++ "license": "MIT", ++ "dependencies": { ++ "asynckit": "^0.4.0", ++ "combined-stream": "^1.0.8", ++ "es-set-tostringtag": "^2.1.0", ++ "hasown": "^2.0.4", ++ "mime-types": "^2.1.35" ++ }, ++ "engines": { ++ "node": ">= 6" ++ } ++ }, ++ "node_modules/function-bind": { ++ "version": "1.1.2", ++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", ++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", ++ "license": "MIT", ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/get-intrinsic": { ++ "version": "1.3.0", ++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", ++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", ++ "license": "MIT", ++ "dependencies": { ++ "call-bind-apply-helpers": "^1.0.2", ++ "es-define-property": "^1.0.1", ++ "es-errors": "^1.3.0", ++ "es-object-atoms": "^1.1.1", ++ "function-bind": "^1.1.2", ++ "get-proto": "^1.0.1", ++ "gopd": "^1.2.0", ++ "has-symbols": "^1.1.0", ++ "hasown": "^2.0.2", ++ "math-intrinsics": "^1.1.0" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/get-proto": { ++ "version": "1.0.1", ++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", ++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", ++ "license": "MIT", ++ "dependencies": { ++ "dunder-proto": "^1.0.1", ++ "es-object-atoms": "^1.0.0" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, ++ "node_modules/gopd": { ++ "version": "1.2.0", ++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", ++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", ++ "license": "MIT", ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/has-symbols": { ++ "version": "1.1.0", ++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", ++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", ++ "license": "MIT", ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/has-tostringtag": { ++ "version": "1.0.2", ++ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", ++ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", ++ "license": "MIT", ++ "dependencies": { ++ "has-symbols": "^1.0.3" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/hasown": { ++ "version": "2.0.4", ++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", ++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", ++ "license": "MIT", ++ "dependencies": { ++ "function-bind": "^1.1.2" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, ++ "node_modules/https-proxy-agent": { ++ "version": "5.0.1", ++ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", ++ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", ++ "license": "MIT", ++ "dependencies": { ++ "agent-base": "6", ++ "debug": "4" ++ }, ++ "engines": { ++ "node": ">= 6" ++ } ++ }, ++ "node_modules/lodash.identity": { ++ "version": "3.0.0", ++ "resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-3.0.0.tgz", ++ "integrity": "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==", ++ "license": "MIT" ++ }, ++ "node_modules/lodash.merge": { ++ "version": "4.6.2", ++ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", ++ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", ++ "license": "MIT" ++ }, ++ "node_modules/lodash.pickby": { ++ "version": "4.6.0", ++ "resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz", ++ "integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==", ++ "license": "MIT" ++ }, ++ "node_modules/long": { ++ "version": "5.3.2", ++ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", ++ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", ++ "license": "Apache-2.0" ++ }, ++ "node_modules/math-intrinsics": { ++ "version": "1.1.0", ++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", ++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", ++ "license": "MIT", ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, ++ "node_modules/mime-db": { ++ "version": "1.52.0", ++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", ++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", ++ "license": "MIT", ++ "engines": { ++ "node": ">= 0.6" ++ } ++ }, ++ "node_modules/mime-types": { ++ "version": "2.1.35", ++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", ++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", ++ "license": "MIT", ++ "dependencies": { ++ "mime-db": "1.52.0" ++ }, ++ "engines": { ++ "node": ">= 0.6" ++ } ++ }, ++ "node_modules/ms": { ++ "version": "2.1.3", ++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", ++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", ++ "license": "MIT" ++ }, ++ "node_modules/object-inspect": { ++ "version": "1.13.4", ++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", ++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", ++ "license": "MIT", ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/protobufjs": { ++ "version": "7.6.6", ++ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", ++ "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", ++ "hasInstallScript": true, ++ "license": "BSD-3-Clause", ++ "dependencies": { ++ "@protobufjs/aspromise": "^1.1.2", ++ "@protobufjs/base64": "^1.1.2", ++ "@protobufjs/codegen": "^2.0.5", ++ "@protobufjs/eventemitter": "^1.1.1", ++ "@protobufjs/fetch": "^1.1.1", ++ "@protobufjs/float": "^1.0.2", ++ "@protobufjs/path": "^1.1.2", ++ "@protobufjs/pool": "^1.1.0", ++ "@protobufjs/utf8": "^1.1.1", ++ "@types/node": ">=13.7.0", ++ "long": "^5.3.2" ++ }, ++ "engines": { ++ "node": ">=12.0.0" ++ } ++ }, ++ "node_modules/proxy-from-env": { ++ "version": "2.1.0", ++ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", ++ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", ++ "license": "MIT", ++ "engines": { ++ "node": ">=10" ++ } ++ }, ++ "node_modules/qs": { ++ "version": "6.16.1", ++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", ++ "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", ++ "license": "BSD-3-Clause", ++ "dependencies": { ++ "es-define-property": "^1.0.1", ++ "side-channel": "^1.1.1" ++ }, ++ "engines": { ++ "node": ">=0.6" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/side-channel": { ++ "version": "1.1.1", ++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", ++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", ++ "license": "MIT", ++ "dependencies": { ++ "es-errors": "^1.3.0", ++ "object-inspect": "^1.13.4", ++ "side-channel-list": "^1.0.1", ++ "side-channel-map": "^1.0.1", ++ "side-channel-weakmap": "^1.0.2" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/side-channel-list": { ++ "version": "1.0.1", ++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", ++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", ++ "license": "MIT", ++ "dependencies": { ++ "es-errors": "^1.3.0", ++ "object-inspect": "^1.13.4" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/side-channel-map": { ++ "version": "1.0.1", ++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", ++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", ++ "license": "MIT", ++ "dependencies": { ++ "call-bound": "^1.0.2", ++ "es-errors": "^1.3.0", ++ "get-intrinsic": "^1.2.5", ++ "object-inspect": "^1.13.3" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/side-channel-weakmap": { ++ "version": "1.0.2", ++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", ++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", ++ "license": "MIT", ++ "dependencies": { ++ "call-bound": "^1.0.2", ++ "es-errors": "^1.3.0", ++ "get-intrinsic": "^1.2.5", ++ "object-inspect": "^1.13.3", ++ "side-channel-map": "^1.0.1" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/undici-types": { ++ "version": "8.9.0", ++ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", ++ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", ++ "license": "MIT" ++ }, ++ "node_modules/ws": { ++ "version": "8.22.0", ++ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", ++ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", ++ "license": "MIT", ++ "engines": { ++ "node": ">=10.0.0" ++ }, ++ "peerDependencies": { ++ "bufferutil": "^4.0.1", ++ "utf-8-validate": ">=5.0.2" ++ }, ++ "peerDependenciesMeta": { ++ "bufferutil": { ++ "optional": true ++ }, ++ "utf-8-validate": { ++ "optional": true ++ } ++ } + } + } + } diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -130,6 +750,16 @@ diff --git a/package.json b/package.json }, "peerDependencies": { "@deepseek-ai/cordis": "*", +@@ -52,6 +52,9 @@ + "@deepseek-ai/dsh-tools": "*", + "@deepseek-ai/dsh-web": "*" + }, ++ "dependencies": { ++ "@larksuiteoapi/node-sdk": "1.76.0" ++ }, + "dsh": { + "client": { + "platform": "web" @@ -62,5 +62,6 @@ }, "devDependencies": { diff --git a/patches/ds-bot/files/src/host/feishu-inbound.js b/patches/ds-bot/files/src/host/feishu-inbound.js index 2a3ecd9f..dae641e2 100644 --- a/patches/ds-bot/files/src/host/feishu-inbound.js +++ b/patches/ds-bot/files/src/host/feishu-inbound.js @@ -68,6 +68,9 @@ export const handleOf = chatId => `feishu:${chatId}` // `transport` is the only piece the dependency decision can change: // { connect(onEvent) -> Promise<{ close(): Promise|void }>, nameOf(openId) -> Promise } +// It may also be a function answering that object, in which case it is called again on every +// `start()`: an adapter that is installed after this module still comes up, and one replaced while +// the connection is off is the one the next start dials. // `deliver({ handle, text, name })` is `remote-chat`'s seam, and `redact` is `rt.redactSecrets`. export function createInbound({ enabled = false, @@ -84,6 +87,12 @@ export function createInbound({ const view = { state: enabled ? 'connecting' : 'off', connected: 0, duplicates: 0, refused: 0, ignored: 0 } let closer let starting + // Bumped by `stop()`: a dial that was already in flight when the owner stopped the connector is + // not the socket this module wants open any more, so the one that arrives is hung up instead of + // adopted, and the row does not flip back to `ready` behind the owner's back. + let generation = 0 + + const socketOf = () => (typeof transport === 'function' ? transport() : transport) const remember = (id) => { if (seen.has(id)) return false @@ -136,30 +145,47 @@ export function createInbound({ function start() { if (!enabled) return Promise.resolve('off') if (starting !== undefined) return starting - starting = (async () => { - if (typeof transport?.connect !== 'function') { - view.state = 'error' - view.error = 'no long-connection transport is installed' - log('the Feishu long connection has no transport: %s', view.error) - return 'error' - } + const socket = socketOf() + // No adapter yet is a configuration state, not a failed dial: nothing is remembered, so a later + // start — a person pressing Try again, once the adapter is there — still dials instead of reading + // the same error back forever. + if (typeof socket?.connect !== 'function') { + view.state = 'error' + view.error = 'no long-connection transport is installed' + log('the Feishu long connection has no transport: %s', view.error) + return Promise.resolve('error') + } + const mine = generation + const attempt = (async () => { try { - closer = await transport.connect(handleEvent) + const opened = await socket.connect(handleEvent) + if (mine !== generation) { + // `stop()` ran while this socket dialled. Hang up what arrived; report it neither ready nor + // as another connection. + await Promise.resolve().then(() => opened?.close?.()).catch(() => { /* an already-dead socket is not an error */ }) + return 'stopped' + } + closer = opened view.state = 'ready' view.connected += 1 delete view.error return 'ready' } catch (error) { + if (mine !== generation) return 'stopped' view.state = 'error' view.error = redact([error, error?.cause].filter(Boolean).map(part => String(part?.message ?? part)).join(': ') || 'connect failed').slice(0, 300) log('the Feishu long connection failed: %s', view.error) return 'error' } })() - return starting + starting = attempt + // A failed dial is not shared either: the next start tries again with whatever is installed then. + void attempt.then(result => { if (starting === attempt && result !== 'ready') starting = undefined }) + return attempt } async function stop() { + generation += 1 starting = undefined const closing = closer closer = undefined diff --git a/patches/ds-bot/files/src/host/feishu-sdk.js b/patches/ds-bot/files/src/host/feishu-sdk.js new file mode 100644 index 00000000..b9f53096 --- /dev/null +++ b/patches/ds-bot/files/src/host/feishu-sdk.js @@ -0,0 +1,80 @@ +// The long-connection adapter over Feishu's own SDK (#1266). This file is the whole of the +// dependency decision: `feishu-inbound.js` keeps the policy and takes a transport, and this adapts +// the SDK's WebSocket client to that `connect(onEvent) -> { close }` shape. A hand-written protobuf +// client would replace this module and nothing else. +// +// The package is imported inside `connect`, never at the top: a host that never turns the long +// connection on, and every test that injects a stub socket, must run without it installed. +// +// `readEvent` reads Feishu's own `{ header, event }` envelope, but the SDK flattens a v2 event +// before it reaches a handler — `event_id` sits beside `message` — so the envelope is rebuilt here +// rather than teaching the policy layer a second shape. +export const SDK_PACKAGE = '@larksuiteoapi/node-sdk' +// A socket that never opens must not hang a start: the row says `error` instead. +export const READY_TIMEOUT_MS = 15_000 + +const within = (promise, ms, message) => { + let timer + const deadline = new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms) + }) + return Promise.race([promise, deadline]).finally(() => clearTimeout(timer)) +} + +export function createFeishuTransport({ + credentials, + importSdk = () => import(SDK_PACKAGE), + log = () => {}, + readyTimeoutMs = READY_TIMEOUT_MS, +} = {}) { + return { + // The app pair is read on every dial, not when the adapter is made: the owner saves it on the + // Secrets page at any time, and a pair that is not there refuses before any import or socket. + async connect(onEvent) { + const app = credentials?.() + if (app === undefined) throw new Error('A Feishu app id and app secret are needed before the long connection can dial') + let sdk + try { + sdk = await importSdk() + } catch (error) { + const why = String(error?.message ?? error).split('\n')[0] + throw new Error(`The Feishu long connection needs ${SDK_PACKAGE} installed: ${why}`) + } + const Lark = sdk?.default ?? sdk + const { WSClient, EventDispatcher } = Lark ?? {} + if (typeof WSClient !== 'function' || typeof EventDispatcher !== 'function') throw new Error(`${SDK_PACKAGE} does not export the long-connection client`) + + const dispatcher = new EventDispatcher({}).register({ + 'im.message.receive_v1': data => onEvent({ + header: { event_id: data?.event_id, event_type: 'im.message.receive_v1' }, + event: { sender: data?.sender, message: data?.message }, + }), + }) + let settle + const opened = new Promise((resolve, reject) => { settle = { resolve, reject } }) + const client = new WSClient({ + appId: app.appId, + appSecret: app.appSecret, + // `onReady` is the first open; a later reconnect is not a second inbox, so it only logs. + onReady: () => settle.resolve(), + onError: error => settle.reject(error), + onReconnecting: () => log('the Feishu long connection is reconnecting'), + }) + try { + await client.start({ eventDispatcher: dispatcher }) + await within(opened, readyTimeoutMs, 'the Feishu long connection did not open') + } catch (error) { + try { client.close({ force: true }) } catch { /* nothing was opened */ } + throw error + } + let closed = false + return { + close: () => { + if (closed) return + closed = true + try { client.close({ force: true }) } catch { /* an already-dead socket is not an error */ } + }, + } + }, + } +} diff --git a/patches/ds-bot/files/test/feishu-inbound.test.js b/patches/ds-bot/files/test/feishu-inbound.test.js index a2da93dc..30f2c5c7 100644 --- a/patches/ds-bot/files/test/feishu-inbound.test.js +++ b/patches/ds-bot/files/test/feishu-inbound.test.js @@ -178,3 +178,43 @@ test('with no socket adapter installed it says so instead of pretending (#1266)' assert.deepEqual(inbound.view().error, 'no long-connection transport is installed') assert.equal(inbound.view().connected, 0) }) + +test('an adapter installed after the first start is the one the next start dials (#1266)', async () => { + // The policy is made before the adapter exists — the shape a module that installs its socket later + // has. A missing adapter must not be remembered as the answer. + const transport = { current: undefined } + const dials = [] + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport: () => transport.current, + deliver: async () => {}, + }) + assert.equal(await inbound.start(), 'error') + assert.equal(inbound.view().error, 'no long-connection transport is installed') + transport.current = { connect: async onEvent => { dials.push(onEvent); return { close: async () => {} } } } + assert.equal(await inbound.start(), 'ready') + assert.equal(dials.length, 1, 'the adapter that arrived late is the one that dialled') + assert.equal(inbound.view().connected, 1) +}) + +test('a stop while the socket dials hangs up the one that arrives and never reports ready (#1266)', async () => { + let release + const dialling = new Promise(resolve => { release = resolve }) + const closed = [] + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport: { + connect: async () => { await dialling; return { close: async () => { closed.push(1) } } }, + }, + deliver: async () => {}, + }) + const started = inbound.start() + await inbound.stop() + release() + assert.equal(await started, 'stopped', 'the dial that outlived stop is not adopted') + assert.deepEqual(closed, [1], 'the socket that arrived after stop is hung up, not leaked') + assert.equal(inbound.view().state, 'connecting', 'the row does not flip back to ready behind the owner') + assert.equal(inbound.view().connected, 0) +}) diff --git a/patches/ds-bot/files/test/feishu-sdk.test.js b/patches/ds-bot/files/test/feishu-sdk.test.js new file mode 100644 index 00000000..4faa21c8 --- /dev/null +++ b/patches/ds-bot/files/test/feishu-sdk.test.js @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { readEvent } from '../src/host/feishu-inbound.js' +import { SDK_PACKAGE, createFeishuTransport } from '../src/host/feishu-sdk.js' + +// #1266: the one adapter that needs Feishu's own SDK. What is asserted here is the seam, not the +// network: the package is imported only when a socket is dialled, the SDK's flattened v2 event is +// handed to the policy as Feishu's own envelope, and a socket that fails or never opens ends the +// start with a line the row can show. + +const APP = { appId: 'cli_0123456789abcdef', appSecret: 'app-secret-value' } + +// A stand-in for the package: it records what the adapter asked it to do. +function fakeSdk({ open = true } = {}) { + const made = [] + class EventDispatcher { + register(handles) { this.handles = handles; return this } + } + class WSClient { + constructor(params) { this.params = params; this.closed = []; made.push(this) } + start({ eventDispatcher }) { + this.handles = eventDispatcher.handles + if (open) this.params.onReady?.() + return Promise.resolve() + } + close(params) { this.closed.push(params ?? {}) } + } + return { made, sdk: { WSClient, EventDispatcher } } +} + +const doesNotImport = () => { throw new Error('the package must not be imported here') } + +test('the SDK is imported at dial time, and its flattened event reaches the policy as Feishu sends it (#1266)', async () => { + const { made, sdk } = fakeSdk() + let imported = 0 + const seen = [] + const transport = createFeishuTransport({ + credentials: () => APP, + importSdk: async () => { imported += 1; return sdk }, + }) + assert.equal(imported, 0, 'nothing is imported before a socket is dialled') + const closer = await transport.connect(envelope => { seen.push(envelope) }) + assert.equal(imported, 1) + assert.equal(made[0].params.appId, APP.appId) + assert.equal(made[0].params.appSecret, APP.appSecret) + + // The SDK merges `header` and `event` into one object (`event_id` beside `message`); the adapter + // rebuilds the envelope `readEvent` reads, so the policy keeps one shape. + await made[0].handles['im.message.receive_v1']({ + event_id: 'e1', + sender: { sender_id: { open_id: 'ou_owner' } }, + message: { chat_id: 'oc_1', message_type: 'text', content: '{"text":"hello there"}' }, + }) + assert.deepEqual(readEvent(seen[0]), { id: 'e1', openId: 'ou_owner', chatId: 'oc_1', text: 'hello there' }) + + closer.close() + assert.deepEqual(made[0].closed, [{ force: true }]) + closer.close() + assert.equal(made[0].closed.length, 1, 'closing twice hangs up once') +}) + +test('a package that is not installed is one readable line, not a stack (#1266)', async () => { + const transport = createFeishuTransport({ + credentials: () => APP, + importSdk: async () => { throw new Error(`Cannot find package '${SDK_PACKAGE}' imported from core`) }, + }) + await assert.rejects(transport.connect(async () => {}), /needs @larksuiteoapi\/node-sdk installed: Cannot find package/) +}) + +test('with no app pair saved nothing is imported and nothing dials (#1266)', async () => { + const transport = createFeishuTransport({ credentials: () => undefined, importSdk: doesNotImport }) + await assert.rejects(transport.connect(async () => {}), /app id and app secret are needed/) +}) + +test('a socket that reports an error ends the start with that error (#1266)', async () => { + class WSClient { + constructor(params) { this.params = params } + start() { this.params.onError(new Error('handshake refused')) } + close() {} + } + class EventDispatcher { register(handles) { this.handles = handles; return this } } + const transport = createFeishuTransport({ credentials: () => APP, importSdk: async () => ({ WSClient, EventDispatcher }) }) + await assert.rejects(transport.connect(async () => {}), /handshake refused/) +}) + +test('a socket that never opens is given up at the deadline instead of hanging the start (#1266)', async () => { + const { made, sdk } = fakeSdk({ open: false }) + const transport = createFeishuTransport({ credentials: () => APP, importSdk: async () => sdk, readyTimeoutMs: 10 }) + await assert.rejects(transport.connect(async () => {}), /did not open/) + assert.equal(made[0].closed.length, 1, 'the half-open client is hung up') +}) diff --git a/patches/ds-bot/files/test/feishu.test.js b/patches/ds-bot/files/test/feishu.test.js index f0755e2a..149f5322 100644 --- a/patches/ds-bot/files/test/feishu.test.js +++ b/patches/ds-bot/files/test/feishu.test.js @@ -61,6 +61,9 @@ async function call(ctx, endpoint, payload) { } const row = (view, id = 'feishu') => (view?.connectors ?? view ?? []).find(entry => entry.id === id) +// #1266: the same row now carries the inbound state (off, no socket, app pair not saved). It is +// spelled out here instead of spread, so a value leaking into the row cannot pass unnoticed. +const INBOUND_OFF = { state: 'off', connected: 0, duplicates: 0, refused: 0, app: 'missing' } const said = (warns, needle) => warns.filter(args => args.some(value => String(value).includes(needle))) // A fake Feishu that records each POST and answers from the script (the last answer repeats). @@ -78,7 +81,7 @@ test('#1263: Feishu is a sender row of its own, off until a webhook is pasted', const { ctx, home } = setup() t.after(async () => { await ctx.dispose(); cleanHome(home) }) const state = await call(ctx, 'state', {}) - assert.deepEqual(row(state.value), { id: 'feishu', name: 'Feishu', state: 'off', kind: 'sender', account: null }) + assert.deepEqual(row(state.value), { id: 'feishu', name: 'Feishu', state: 'off', kind: 'sender', account: null, inbound: INBOUND_OFF }) // A sender has no tools, so it is not a name a saved capability may take either way. assert.deepEqual(state.value.connectors.map(entry => entry.id), ['github', 'composio', 'feishu']) }) @@ -94,7 +97,7 @@ test('#1263: connecting posts one test message, turns ready, and keeps the webho const connected = await call(ctx, 'connector-connect', { id: 'feishu', token: ` ${webhook} ` }) assert.equal(connected.ok, true, JSON.stringify(connected)) - assert.deepEqual(row(connected.value), { id: 'feishu', name: 'Feishu', state: 'ready', kind: 'sender', account: null }) + assert.deepEqual(row(connected.value), { id: 'feishu', name: 'Feishu', state: 'ready', kind: 'sender', account: null, inbound: INBOUND_OFF }) assert.equal(feishu.posts.length, 1, 'one test POST on connect') assert.equal(feishu.posts[0].url, webhook) From 0946f4c13a87ba36df58fdd3381ee2e487450bc3 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Mon, 26 Oct 2026 19:14:41 +0800 Subject: [PATCH 3/3] fix(feishu): give the answer a way out and keep a failed handover (#1266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Reviewer's INC at 02e3ed67, two code defects: 1. A reply had no exit. `remote-chat.forward` keys its sender by `bot.remote.plugin`, and the inbound path called `deliver` without one, so every chat was opened with `remote.plugin === undefined` and every answer read "Not sent: the plugin that reaches this Bot is not running." The chat now carries the `feishu` plugin, and `connectors.js` registers that plugin's sender on the seam (`onSend`) the first time a message lands — `remote-chat` installs after this module, so it cannot be registered while module install runs. The sender posts on the `FEISHU_WEBHOOK` phase 1 already uses, and a missing webhook or a refusal is a "Not sent:" sentence in the chat, never a silent drop. `open` refreshes a chat an older build opened without the plugin. 2. 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 now enters the ring only after the handover returned; an `inFlight` set keeps two copies arriving at once from starting two turns in that gap. Tests: a message whose delivery throws is handed over again (ring written after), two copies at once still one turn, the chat opens on the feishu plugin, the sender is registered when the first message lands, the answer leaves on the webhook (missing webhook / refusal worded as "Not sent:"), and a composed check that a reply typed into a `feishu:` chat reaches that sender. --- changelog.d/1266-feishu-inbound.added.md | 2 +- core/src/host/connectors.js | 44 +++++- core/src/host/feishu-inbound.js | 18 ++- core/test/connectors.test.js | 67 +++++++- core/test/feishu-inbound.test.js | 45 ++++++ core/test/remote-chat.test.js | 22 +++ docs/subsystems/core-host.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 143 +++++++++++++++--- .../ds-bot/files/src/host/feishu-inbound.js | 18 ++- .../ds-bot/files/test/feishu-inbound.test.js | 45 ++++++ patches/ds-bot/files/test/remote-chat.test.js | 22 +++ 11 files changed, 394 insertions(+), 34 deletions(-) diff --git a/changelog.d/1266-feishu-inbound.added.md b/changelog.d/1266-feishu-inbound.added.md index 996045b5..fb31abe3 100644 --- a/changelog.d/1266-feishu-inbound.added.md +++ b/changelog.d/1266-feishu-inbound.added.md @@ -1 +1 @@ -Feishu (Lark) connector, phase 2: a message the owner types in Feishu can start a turn here over the long connection. The app pair `FEISHU_APP_ID` / `FEISHU_APP_SECRET` is saved through `secret-set` and stays out of `state.json` and the view; the connection is off until `feishu.enabled` is true, dials only from an app pair that is complete, and only the owner's messages start a turn. A replayed `event_id` lands once, the message arrives through the same chat seam a friend's Bot uses, and the reply leaves through the Feishu sender — so there is no second way into or out of a chat. ([#1266](https://github.com/ThinkFlowLab/FlyBot/issues/1266)) +Feishu (Lark) connector, phase 2: a message the owner types in Feishu can start a turn here over the long connection. The app pair `FEISHU_APP_ID` / `FEISHU_APP_SECRET` is saved through `secret-set` and stays out of `state.json` and the view; the connection is off until `feishu.enabled` is true, dials only from an app pair that is complete, and only the owner's messages start a turn. A replayed `event_id` lands once — the id is remembered only after the message reached its chat, so a handover that failed is retried rather than dropped — the message arrives through the same chat seam a friend's Bot uses, and the answer leaves again on the same `FEISHU_WEBHOOK` the connector already posts with, so one chat has one way in and one way out. ([#1266](https://github.com/ThinkFlowLab/FlyBot/issues/1266)) diff --git a/core/src/host/connectors.js b/core/src/host/connectors.js index 8bbdeab8..3753869b 100644 --- a/core/src/host/connectors.js +++ b/core/src/host/connectors.js @@ -318,6 +318,20 @@ export function feishuTransport(webhook, { fetch: request = fetch, timeoutMs = S return { postMessage: message => feishuPost(webhook, message?.text ?? '', { fetch: request, timeoutMs }) } } +// The way out for an answer to a Feishu message (#1266): the webhook phase 1 already posts on, so a +// message in and an answer out are one credential and one transport. Kept out of `install` so the +// exit can be asserted without a socket. A missing webhook or a refusal is a sentence the chat +// shows ("Not sent: …"), never a silent drop: `remote-chat` prints exactly what is thrown here. +export function feishuReply({ secretValue, post = feishuPost, timeoutMs = SEND_TIMEOUT_MS } = {}) { + return async ({ text }) => { + const webhook = secretValue?.(FEISHU_SECRET) + if (webhook === undefined) throw new Error('Not sent: connect Feishu first, so the answer has a webhook to leave on.') + const answer = await post(webhook, String(text ?? ''), { timeoutMs }) + if (answer?.ok === true) return + throw new Error(`Not sent: Feishu refused the answer (${answer?.error ?? 'no answer'})`) + } +} + const DEFINITIONS = { [GITHUB]: { id: GITHUB, @@ -420,8 +434,8 @@ export function install(rt) { // `rt.feishuTransport` is the one adapter the SDK-versus-hand-written decision changes, so nothing // below depends on which client dials. Off unless the owner turned it on, and with no socket adapter // it starts nothing rather than pretend. The message lands through `remote-chat.deliver` — the same - // seam a friend's Bot uses — and the reply comes back through the sender above (`onSend`), so there - // is no second path into or out of a chat. + // seam a friend's Bot uses — and the answer leaves through the sender this module registers in that + // same seam, on the webhook phase 1 posts on: one path in, one path out. // // The default adapter is the official SDK, and it is put on the runtime *before* the policy reads // it. That read happens on every `start()` rather than once here, so an adapter installed later @@ -433,13 +447,33 @@ export function install(rt) { log: (format, ...args) => rt.warn(`ds-bot: ${format}`, ...args), }) } + // One way out per Feishu chat: `remote-chat` keys a reply's sender by the chat's plugin, so this + // registers the webhook post under `feishu`. Registered against the seam object it saw, so a runtime + // that replaces the seam is wired again instead of keeping a dead registration. + let feishuWire = { seam: undefined, off: undefined } + const wireFeishuReply = () => { + const seam = rt.remoteChats + if (typeof seam?.onSend !== 'function') return + if (feishuWire.seam === seam) return + feishuWire.off?.() + feishuWire = { seam, off: seam.onSend(FEISHU, feishuReply({ secretValue: name => rt.secretValue(name) })) } + } + const feishuInbound = createInbound({ enabled: rt.config?.feishu?.enabled === true, ownerOpenId: rt.config?.feishu?.ownerOpenId, transport: () => rt.feishuTransport, deliver: async ({ handle, text, name }) => { - if (typeof rt.remoteChats?.deliver !== 'function') throw new Error('no chat seam is installed for a Feishu message') - await rt.remoteChats.deliver({ handle, text, name }) + const seam = rt.remoteChats + if (typeof seam?.deliver !== 'function') throw new Error('no chat seam is installed for a Feishu message') + // The seam is installed after this module, so the answer's sender is registered when the first + // message lands rather than while module install runs. + wireFeishuReply() + // `plugin` is what `remote-chat.forward` keys the sender by. Without it the chat is opened with + // `remote.plugin === undefined` and every answer reads "Not sent: the plugin that reaches this + // Bot is not running." `open` refreshes a chat that an older build opened without the plugin. + if (typeof seam.open === 'function') await seam.open({ handle, name, plugin: FEISHU }) + await seam.deliver({ handle, text, name, plugin: FEISHU }) }, log: (format, ...args) => rt.warn(`ds-bot: ${format}`, ...args), redact: rt.redactSecrets, @@ -751,6 +785,8 @@ export function install(rt) { }) return () => { stopped = true + feishuWire.off?.() + feishuWire = { seam: undefined, off: undefined } void feishuInbound.stop() for (const c of connectors) void unmount(c) } diff --git a/core/src/host/feishu-inbound.js b/core/src/host/feishu-inbound.js index dae641e2..3b53471c 100644 --- a/core/src/host/feishu-inbound.js +++ b/core/src/host/feishu-inbound.js @@ -84,6 +84,10 @@ export function createInbound({ } = {}) { const seen = new Set() const order = [] + // Events being handed over right now. Two copies of one event can arrive while the first is still + // in `deliver`; the ring is written only after that resolves (#1266 INC), so this is what keeps the + // second copy from starting a second turn in the gap. + const inFlight = new Set() const view = { state: enabled ? 'connecting' : 'off', connected: 0, duplicates: 0, refused: 0, ignored: 0 } let closer let starting @@ -129,13 +133,21 @@ export function createInbound({ log('a Feishu message from %s is not the owner; no turn started', read.openId) return 'refused' } - if (!remember(read.id)) { + if (seen.has(read.id) || inFlight.has(read.id)) { view.duplicates += 1 log('a Feishu event %s was already handled; duplicate ignored', read.id) return 'duplicate' } - const name = (await nameOf(read.openId)) ?? handleOf(read.chatId) - await deliver({ handle: handleOf(read.chatId), text: read.text, name }) + inFlight.add(read.id) + try { + const name = (await nameOf(read.openId)) ?? handleOf(read.chatId) + await deliver({ handle: handleOf(read.chatId), text: read.text, name }) + } finally { + inFlight.delete(read.id) + } + // Only now is the id handled: a `deliver` that threw leaves it out of the ring, so a replayed + // event is handed over again instead of being called a duplicate for ever (Reviewer, #1266). + remember(read.id) view.lastEventAt = new Date(now()).toISOString() return 'delivered' } diff --git a/core/test/connectors.test.js b/core/test/connectors.test.js index 759f147b..36d7b486 100644 --- a/core/test/connectors.test.js +++ b/core/test/connectors.test.js @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import { apply, createRuntime } from '../index.js' -import { cleanApps, composioAppStatus, composioDeleteSession, composioSearchApps, composioSession, composioSessionAlive, composioSessionFind, signIn } from '../src/host/connectors.js' +import { cleanApps, composioAppStatus, composioDeleteSession, composioSearchApps, composioSession, composioSessionAlive, composioSessionFind, feishuReply, signIn } from '../src/host/connectors.js' import { fakeContext } from './fake-ctx.js' // A test's temp home can still be written by a background save that outlives dispose(), so a @@ -451,6 +451,71 @@ test('an adapter installed after the connectors module is the one the next Try a assert.equal(dials.length, 1, 'the adapter installed late is the one that dialled') }) +// #1266 INC (Reviewer): an answer to a Feishu message had no way out. `remote-chat.forward` keys its +// sender by `bot.remote.plugin`, and this module opened the chat without one, so every answer read +// "Not sent: the plugin that reaches this Bot is not running." The chat now carries the feishu plugin. +const FEISHU_INBOUND_EVENT = { + header: { event_id: 'e1', event_type: 'im.message.receive_v1' }, + event: { + sender: { sender_id: { open_id: 'ou_owner_1' } }, + message: { chat_id: 'oc_chat_1', message_type: 'text', content: JSON.stringify({ text: 'hello there' }) }, + }, +} + +// The inbound socket of a running runtime, dialled through Try again so the adapter is a stub: what +// comes back is the `onEvent` the policy layer handed the socket. +async function feishuInboundRig(t) { + const home = teamHome() + const ctx = fakeContext() + capture(ctx) + const rt = createRuntime(ctx, { home, feishu: { enabled: true, ownerOpenId: 'ou_owner_1' } }) + t.after(async () => { await ctx.dispose(); await cleanHome(home) }) + const dials = [] + rt.feishuTransport = { connect: async (onEvent) => { dials.push(onEvent); return { close: async () => {} } } } + await call(ctx, 'connector-retry', { id: 'feishu' }) + assert.ok(dials.length > 0, 'the stub socket dialled') + return { rt, ctx, home, onEvent: dials.at(-1) } +} + +test('a Feishu message opens its chat on the feishu plugin (#1266)', async (t) => { + const { rt, onEvent } = await feishuInboundRig(t) + const delivered = [] + rt.remoteChats.open = async () => 'chat' + rt.remoteChats.deliver = async message => { delivered.push(message); return 'chat' } + await onEvent(FEISHU_INBOUND_EVENT) + assert.equal(delivered.length, 1, 'the message reached the chat seam') + assert.equal(delivered[0].handle, 'feishu:oc_chat_1') + assert.equal(delivered[0].text, 'hello there') + assert.equal(delivered[0].plugin, 'feishu', 'without the plugin the chat forwards its answers to no sender') +}) + +test('the answer sender is registered on the chat seam when a Feishu message arrives (#1266)', async (t) => { + const { rt, onEvent } = await feishuInboundRig(t) + const senders = new Map() + rt.remoteChats.open = async () => 'chat' + rt.remoteChats.deliver = async () => 'chat' + rt.remoteChats.onSend = (plugin, fn) => { senders.set(plugin, fn); return () => { senders.delete(plugin) } } + await onEvent(FEISHU_INBOUND_EVENT) + assert.equal(typeof senders.get('feishu'), 'function', 'remote-chat installs after this module, so the sender is registered when the first message lands') +}) + +test('the answer to a Feishu message leaves on the webhook phase 1 already posts to (#1266)', async () => { + const posts = [] + const reply = feishuReply({ + secretValue: name => (name === 'FEISHU_WEBHOOK' ? 'https://open.feishu.cn/open-apis/bot/v2/hook/abc' : undefined), + post: async (webhook, text) => { posts.push([webhook, text]); return { ok: true } }, + }) + await reply({ handle: 'feishu:oc_chat_1', text: 'the answer' }) + assert.deepEqual(posts, [['https://open.feishu.cn/open-apis/bot/v2/hook/abc', 'the answer']]) + // A missing webhook is a sentence the chat shows, not a silent drop: `remote-chat` prints it as + // the "Not sent:" line it already knows. + await assert.rejects(feishuReply({ secretValue: () => undefined })({ text: 'x' }), /^Error: Not sent: connect Feishu first/) + await assert.rejects( + feishuReply({ secretValue: () => 'https://open.feishu.cn/open-apis/bot/v2/hook/abc', post: async () => ({ ok: false, error: 'feishu/http-400' }) })({ text: 'x' }), + /Not sent: Feishu refused the answer \(feishu\/http-400\)/, + ) +}) + test('a Composio address without https refuses to send the key', async (t) => { const realFetch = globalThis.fetch t.after(() => { globalThis.fetch = realFetch }) diff --git a/core/test/feishu-inbound.test.js b/core/test/feishu-inbound.test.js index 30f2c5c7..9f9e56cc 100644 --- a/core/test/feishu-inbound.test.js +++ b/core/test/feishu-inbound.test.js @@ -218,3 +218,48 @@ test('a stop while the socket dials hangs up the one that arrives and never repo assert.equal(inbound.view().state, 'connecting', 'the row does not flip back to ready behind the owner') assert.equal(inbound.view().connected, 0) }) + +// #1266 INC (Reviewer): the ring used to be written *before* `deliver`, so a delivery that threw +// left the id in it and every replay read `duplicate` — a lost message the owner could never get +// back. The id now enters the ring only once the message has been handed to its chat. +test('a message whose delivery throws is handed over again, not remembered as handled (#1266)', async () => { + const turns = [] + let refuse = true + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport: { connect: async () => ({ close: async () => {} }) }, + deliver: async message => { + if (refuse) { + refuse = false + throw new Error('no chat seam is installed for a Feishu message') + } + turns.push(message) + }, + }) + await inbound.start() + await assert.rejects(inbound.handleEvent(event()), /no chat seam is installed/) + assert.equal(await inbound.handleEvent(event()), 'delivered', 'the same event_id is handled again') + assert.equal(turns.length, 1) + assert.equal(inbound.view().duplicates, 0, 'a failed delivery is not a duplicate') + assert.equal(inbound.seen(), 1, 'the ring holds the id once, after the delivery succeeded') +}) + +test('two copies of one event arriving together still start one turn (#1266)', async () => { + const turns = [] + let release + const handed = new Promise(resolve => { release = resolve }) + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport: { connect: async () => ({ close: async () => {} }) }, + deliver: async message => { await handed; turns.push(message) }, + }) + await inbound.start() + const first = inbound.handleEvent(event()) + const second = inbound.handleEvent(event()) + release() + const results = await Promise.all([first, second]) + assert.deepEqual(results.sort(), ['delivered', 'duplicate']) + assert.equal(turns.length, 1) +}) diff --git a/core/test/remote-chat.test.js b/core/test/remote-chat.test.js index 4f5902ed..39b95d85 100644 --- a/core/test/remote-chat.test.js +++ b/core/test/remote-chat.test.js @@ -2,6 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { remoteTurn, isRemoteChat, REMOTE_PROVIDER, install, notSentLine } from '../src/host/remote-chat.js'; import { isHomeLevelRemote, isListedRemoteChat, isShownRemoteChat } from '../src/shared/remote-label.js'; +import { feishuReply } from '../src/host/connectors.js'; const user = (content, source, id = 'm1') => ({ role: 'user', id, content, ...(source ? { source } : {}) }); @@ -282,3 +283,24 @@ test('a line Bot Social attests the friend typed themselves carries the person\' assert.equal(followups[0].source.form, 'remote-chat') assert.equal(followups[1].source.text, 'bot line') }) + +// #1266 INC (Reviewer): the whole exit of a Feishu answer, composed. A `feishu:` chat carries the +// feishu plugin, the plugin's sender is the one `connectors.js` registers, and a message typed into +// that chat leaves on the webhook — not as "Not sent: the plugin that reaches this Bot is not running." +test('a reply typed into a feishu chat leaves on the Feishu webhook (#1266)', async () => { + const state = { bots: { f1: { id: 'f1', name: '飞书 Owner', remote: { handle: 'feishu:oc_chat_1', plugin: 'feishu' } } } } + let adapter + const posts = [] + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: { llm: { registerAdapter: (_, a) => { adapter = a } } } } + install(rt) + rt.remoteChats.onSend('feishu', feishuReply({ + secretValue: name => (name === 'FEISHU_WEBHOOK' ? 'https://open.feishu.cn/open-apis/bot/v2/hook/abc' : undefined), + post: async (webhook, text) => { posts.push([webhook, text]); return { ok: true } }, + })) + let said = '' + for await (const event of adapter.stream({ model: 'feishu:oc_chat_1', messages: [{ role: 'user', id: 'm1', content: [{ type: 'text', text: 'the answer' }] }] })) { + if (event.type === 'text-delta') said += event.text + } + assert.equal(said, '', 'nothing is drawn for a send that left') + assert.deepEqual(posts, [['https://open.feishu.cn/open-apis/bot/v2/hook/abc', 'the answer']]) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index f6413d5b..5b6a6809 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -337,7 +337,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi Feishu (Lark) is the first **sender** connector (#1263): a group's custom-bot webhook is one HTTPS POST into the instance's own group, so nothing is mounted, no Bot's tool list changes, and `connector-access.js`'s `CONNECTOR_IDS` does not list it. Its definition carries `kind: 'sender'`, which the view reports and the client reads instead of hard-coding an id. A sender stops at the status: a saved webhook reads `ready` without posting, because the test POST belongs to the **connect** step, so a restart never messages the group; `connector-retry` ("Try again") is the one path that proves it again. The webhook is `FEISHU_WEBHOOK`, saved through `secret-set` **before** the probe — so a refusal stays on the row as `error` with the reason and `redactSecrets` can mask it, the way a failed mount does for GitHub — and it must be https on the configured `feishu.api` origin, else `A Feishu webhook must be https on `: the URL is the credential, so it may never be posted to another host and never appears in `state.json`, the view or a warn line. `connector-send { id, text }` posts one message on demand and records `lastOkAt` / `lastError` in `state.connectors.feishu`; nothing posts room traffic by itself. Feishu answers HTTP 200 with the verdict in the body (`code: 0` success, `9499` a rate limit, `19021` a refused signature), so `feishuPost` maps the body into notify.js's vocabulary and the retry policy stays `notify.js`'s `createNotifier` (3 attempts, backoff, honour `retry-after`, structural errors not retried) rather than a second one written here; `connector-send` uses a fresh idempotency key per call, so two messages with the same text are two messages. The connect probe and a send both carry a deadline (15 s and 60 s) and `withDeadline` races the fetch, so a fetch that ignores its abort signal cannot hang either. `feishu: false` hides the row; `connector-send` is in `SHARED_BLOCKED` like the other connector writes. -The same row carries a Feishu message coming **in** (#1266): `inbound` reports `off|connecting|ready|error`, the last event, the last error and whether the app pair is `set` or `missing` — never a value. `src/host/feishu-inbound.js` owns the policy: off unless `feishu.enabled` is true (an off connector dials nothing), the owner is the only sender that may start a turn (an unset or empty `feishu.ownerOpenId` refuses everyone, and an empty `open_id` never compares equal), a bounded ring of 512 `event_id`s makes a replay land once, and one socket per connector shares a start. The socket itself is injected as `rt.feishuTransport` (`connect(onEvent) -> { close }`) so the client behind it is one adapter, and it is read again on every start rather than copied while the modules install — an adapter that arrives later comes up on the row's **Try again**, which retries the sender and the socket together; a stop that lands while a socket dials hangs up the one that arrives instead of adopting it. `src/host/feishu-sdk.js` is the default adapter over the official `@larksuiteoapi/node-sdk`, imported only when a socket is dialled and reading the app pair at that moment, so a host that never turns the long connection on pays nothing for the package; the message lands through `remote-chat.deliver`, the seam a friend's Bot already uses, and the reply leaves through the sender above — there is no second path into or out of a chat. `FEISHU_APP_ID` + `FEISHU_APP_SECRET` are read as one credential through the same `rt.secretValue`, and a half-saved pair reports `missing` and dials nothing. +The same row carries a Feishu message coming **in** (#1266): `inbound` reports `off|connecting|ready|error`, the last event, the last error and whether the app pair is `set` or `missing` — never a value. `src/host/feishu-inbound.js` owns the policy: off unless `feishu.enabled` is true (an off connector dials nothing), the owner is the only sender that may start a turn (an unset or empty `feishu.ownerOpenId` refuses everyone, and an empty `open_id` never compares equal), a bounded ring of 512 `event_id`s makes a replay land once — the id enters it only after `deliver` has returned, so a handover that threw is retried instead of being refused as a duplicate for ever — and one socket per connector shares a start. The socket itself is injected as `rt.feishuTransport` (`connect(onEvent) -> { close }`) so the client behind it is one adapter, and it is read again on every start rather than copied while the modules install — an adapter that arrives later comes up on the row's **Try again**, which retries the sender and the socket together; a stop that lands while a socket dials hangs up the one that arrives instead of adopting it. `src/host/feishu-sdk.js` is the default adapter over the official `@larksuiteoapi/node-sdk`, imported only when a socket is dialled and reading the app pair at that moment, so a host that never turns the long connection on pays nothing for the package; the message lands through `remote-chat.deliver`, the seam a friend's Bot already uses, and the answer leaves again through the sender this module registers in that seam under the `feishu` plugin, posting on the same `FEISHU_WEBHOOK` `feishuPost` has used since phase 1 (a chat an older build opened without the plugin is refreshed on its next message) — one path in, one path out. `FEISHU_APP_ID` + `FEISHU_APP_SECRET` are read as one credential through the same `rt.secretValue`, and a half-saved pair reports `missing` and dials nothing. ### Secret scope defaults (#545) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 3e3898ca..fea679ad 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -2671,7 +2671,7 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js let response try { response = await request(`${api.replace(/\/$/, '')}/user`, { -@@ -38,132 +38,693 @@ export async function signIn(token, { api = DEFAULTS.api, fetch: request = fetch +@@ -38,132 +38,729 @@ export async function signIn(token, { api = DEFAULTS.api, fetch: request = fetch return { login: user.login } } @@ -2925,6 +2925,20 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + return { postMessage: message => feishuPost(webhook, message?.text ?? '', { fetch: request, timeoutMs }) } +} + ++// The way out for an answer to a Feishu message (#1266): the webhook phase 1 already posts on, so a ++// message in and an answer out are one credential and one transport. Kept out of `install` so the ++// exit can be asserted without a socket. A missing webhook or a refusal is a sentence the chat ++// shows ("Not sent: …"), never a silent drop: `remote-chat` prints exactly what is thrown here. ++export function feishuReply({ secretValue, post = feishuPost, timeoutMs = SEND_TIMEOUT_MS } = {}) { ++ return async ({ text }) => { ++ const webhook = secretValue?.(FEISHU_SECRET) ++ if (webhook === undefined) throw new Error('Not sent: connect Feishu first, so the answer has a webhook to leave on.') ++ const answer = await post(webhook, String(text ?? ''), { timeoutMs }) ++ if (answer?.ok === true) return ++ throw new Error(`Not sent: Feishu refused the answer (${answer?.error ?? 'no answer'})`) ++ } ++} ++ +const DEFINITIONS = { + [GITHUB]: { + id: GITHUB, @@ -3037,8 +3051,8 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + // `rt.feishuTransport` is the one adapter the SDK-versus-hand-written decision changes, so nothing + // below depends on which client dials. Off unless the owner turned it on, and with no socket adapter + // it starts nothing rather than pretend. The message lands through `remote-chat.deliver` — the same -+ // seam a friend's Bot uses — and the reply comes back through the sender above (`onSend`), so there -+ // is no second path into or out of a chat. ++ // seam a friend's Bot uses — and the answer leaves through the sender this module registers in that ++ // same seam, on the webhook phase 1 posts on: one path in, one path out. + // + // The default adapter is the official SDK, and it is put on the runtime *before* the policy reads + // it. That read happens on every `start()` rather than once here, so an adapter installed later @@ -3050,13 +3064,33 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + log: (format, ...args) => rt.warn(`ds-bot: ${format}`, ...args), + }) + } ++ // One way out per Feishu chat: `remote-chat` keys a reply's sender by the chat's plugin, so this ++ // registers the webhook post under `feishu`. Registered against the seam object it saw, so a runtime ++ // that replaces the seam is wired again instead of keeping a dead registration. ++ let feishuWire = { seam: undefined, off: undefined } ++ const wireFeishuReply = () => { ++ const seam = rt.remoteChats ++ if (typeof seam?.onSend !== 'function') return ++ if (feishuWire.seam === seam) return ++ feishuWire.off?.() ++ feishuWire = { seam, off: seam.onSend(FEISHU, feishuReply({ secretValue: name => rt.secretValue(name) })) } ++ } ++ + const feishuInbound = createInbound({ + enabled: rt.config?.feishu?.enabled === true, + ownerOpenId: rt.config?.feishu?.ownerOpenId, + transport: () => rt.feishuTransport, + deliver: async ({ handle, text, name }) => { -+ if (typeof rt.remoteChats?.deliver !== 'function') throw new Error('no chat seam is installed for a Feishu message') -+ await rt.remoteChats.deliver({ handle, text, name }) ++ const seam = rt.remoteChats ++ if (typeof seam?.deliver !== 'function') throw new Error('no chat seam is installed for a Feishu message') ++ // The seam is installed after this module, so the answer's sender is registered when the first ++ // message lands rather than while module install runs. ++ wireFeishuReply() ++ // `plugin` is what `remote-chat.forward` keys the sender by. Without it the chat is opened with ++ // `remote.plugin === undefined` and every answer reads "Not sent: the plugin that reaches this ++ // Bot is not running." `open` refreshes a chat that an older build opened without the plugin. ++ if (typeof seam.open === 'function') await seam.open({ handle, name, plugin: FEISHU }) ++ await seam.deliver({ handle, text, name, plugin: FEISHU }) + }, + log: (format, ...args) => rt.warn(`ds-bot: ${format}`, ...args), + redact: rt.redactSecrets, @@ -3067,9 +3101,8 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + const fiber = c.mount?.fiber + c.mount = null + if (fiber !== undefined) await Promise.resolve().then(() => fiber.dispose()).catch(() => { /* a fiber that fails to dispose must not block unloading */ }) - } - -- // The MCP client wraps the server's answer (a 401, say) as the cause. ++ } ++ + // A sender's retry policy is notify.js's, not a second one written here: one transport per + // sender, reading the saved webhook when it is called, so a reconnect needs no rebuild. + const notifiers = new Map(connectors.filter(c => c.def.kind === 'sender').map(c => [c.def.id, createNotifier({ @@ -3095,8 +3128,9 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + c.status = { state: 'error', error: reason(error) } + rt.warn('ds-bot: %s connector failed: %s', c.def.name, c.status.error) + } -+ } -+ + } + +- // The MCP client wraps the server's answer (a 401, say) as the cause. + // The MCP client wraps the server's answer (a 401, say) as the cause. The session address of a + // mount in progress is not kept anywhere, so it is also taken out of what is shown or logged. + let addresses = [] @@ -3122,16 +3156,16 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js - status = { state: 'off' } + notifierOf(c)?.setEnabled(false) + c.status = { state: 'off' } ++ return ++ } ++ if (c.insecure) { ++ c.mount = { key } ++ c.status = { state: 'error', error: INSECURE(def.name) } return } - if (insecure) { - mount = { token } - status = { state: 'error', error: INSECURE } -+ if (c.insecure) { -+ c.mount = { key } -+ c.status = { state: 'error', error: INSECURE(def.name) } -+ return -+ } + c.status = { state: 'connecting' } + // A sender mounts nothing: the test POST belongs to the connect step, so a start-up (or a + // secret change) only reports that a webhook is saved — a restart must not post to the group @@ -3289,8 +3323,8 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + } + await retire(c, { gone: previous?.session, kept: record.session, keys: [oldToken, token] }) + await sync(c) - return connectorsView() - }, ++ return connectorsView() ++ }, + // Changes which apps a connected gateway offers, without asking for the key again. + async 'connector-apps'(body) { + const c = known(body.id) @@ -3309,8 +3343,8 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + } + await retire(c, { gone: previous?.session, kept: record.session, keys: [token] }) + await sync(c) -+ return connectorsView() -+ }, + return connectorsView() + }, + // Searches Composio's app list: with the saved key, or before connecting with the key being pasted + // (sent only to Composio, never saved by this call). + async 'connector-app-search'(body) { @@ -3421,6 +3455,8 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js return () => { stopped = true - void unmount() ++ feishuWire.off?.() ++ feishuWire = { seam: undefined, off: undefined } + void feishuInbound.stop() + for (const c of connectors) void unmount(c) } @@ -6242,7 +6278,7 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js -import { apply } from '../index.js' -import { signIn } from '../src/host/connectors.js' +import { apply, createRuntime } from '../index.js' -+import { cleanApps, composioAppStatus, composioDeleteSession, composioSearchApps, composioSession, composioSessionAlive, composioSessionFind, signIn } from '../src/host/connectors.js' ++import { cleanApps, composioAppStatus, composioDeleteSession, composioSearchApps, composioSession, composioSessionAlive, composioSessionFind, feishuReply, signIn } from '../src/host/connectors.js' import { fakeContext } from './fake-ctx.js' +// A test's temp home can still be written by a background save that outlives dispose(), so a @@ -6419,7 +6455,7 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js }) test('a GitHub address without https refuses to send the token', async (t) => { -@@ -165,10 +165,647 @@ test('a GitHub address without https refuses to send the token', async (t) => { +@@ -165,10 +165,712 @@ test('a GitHub address without https refuses to send the token', async (t) => { let asked = false globalThis.fetch = async () => { asked = true; return answer(200, { login: 'octo' }) } const { ctx, home, mounts } = setup({ github: { url: 'http://mcp.test/', api: 'https://gh.test' } }) @@ -6659,6 +6695,71 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js + assert.equal(dials.length, 1, 'the adapter installed late is the one that dialled') +}) + ++// #1266 INC (Reviewer): an answer to a Feishu message had no way out. `remote-chat.forward` keys its ++// sender by `bot.remote.plugin`, and this module opened the chat without one, so every answer read ++// "Not sent: the plugin that reaches this Bot is not running." The chat now carries the feishu plugin. ++const FEISHU_INBOUND_EVENT = { ++ header: { event_id: 'e1', event_type: 'im.message.receive_v1' }, ++ event: { ++ sender: { sender_id: { open_id: 'ou_owner_1' } }, ++ message: { chat_id: 'oc_chat_1', message_type: 'text', content: JSON.stringify({ text: 'hello there' }) }, ++ }, ++} ++ ++// The inbound socket of a running runtime, dialled through Try again so the adapter is a stub: what ++// comes back is the `onEvent` the policy layer handed the socket. ++async function feishuInboundRig(t) { ++ const home = teamHome() ++ const ctx = fakeContext() ++ capture(ctx) ++ const rt = createRuntime(ctx, { home, feishu: { enabled: true, ownerOpenId: 'ou_owner_1' } }) ++ t.after(async () => { await ctx.dispose(); await cleanHome(home) }) ++ const dials = [] ++ rt.feishuTransport = { connect: async (onEvent) => { dials.push(onEvent); return { close: async () => {} } } } ++ await call(ctx, 'connector-retry', { id: 'feishu' }) ++ assert.ok(dials.length > 0, 'the stub socket dialled') ++ return { rt, ctx, home, onEvent: dials.at(-1) } ++} ++ ++test('a Feishu message opens its chat on the feishu plugin (#1266)', async (t) => { ++ const { rt, onEvent } = await feishuInboundRig(t) ++ const delivered = [] ++ rt.remoteChats.open = async () => 'chat' ++ rt.remoteChats.deliver = async message => { delivered.push(message); return 'chat' } ++ await onEvent(FEISHU_INBOUND_EVENT) ++ assert.equal(delivered.length, 1, 'the message reached the chat seam') ++ assert.equal(delivered[0].handle, 'feishu:oc_chat_1') ++ assert.equal(delivered[0].text, 'hello there') ++ assert.equal(delivered[0].plugin, 'feishu', 'without the plugin the chat forwards its answers to no sender') ++}) ++ ++test('the answer sender is registered on the chat seam when a Feishu message arrives (#1266)', async (t) => { ++ const { rt, onEvent } = await feishuInboundRig(t) ++ const senders = new Map() ++ rt.remoteChats.open = async () => 'chat' ++ rt.remoteChats.deliver = async () => 'chat' ++ rt.remoteChats.onSend = (plugin, fn) => { senders.set(plugin, fn); return () => { senders.delete(plugin) } } ++ await onEvent(FEISHU_INBOUND_EVENT) ++ assert.equal(typeof senders.get('feishu'), 'function', 'remote-chat installs after this module, so the sender is registered when the first message lands') ++}) ++ ++test('the answer to a Feishu message leaves on the webhook phase 1 already posts to (#1266)', async () => { ++ const posts = [] ++ const reply = feishuReply({ ++ secretValue: name => (name === 'FEISHU_WEBHOOK' ? 'https://open.feishu.cn/open-apis/bot/v2/hook/abc' : undefined), ++ post: async (webhook, text) => { posts.push([webhook, text]); return { ok: true } }, ++ }) ++ await reply({ handle: 'feishu:oc_chat_1', text: 'the answer' }) ++ assert.deepEqual(posts, [['https://open.feishu.cn/open-apis/bot/v2/hook/abc', 'the answer']]) ++ // A missing webhook is a sentence the chat shows, not a silent drop: `remote-chat` prints it as ++ // the "Not sent:" line it already knows. ++ await assert.rejects(feishuReply({ secretValue: () => undefined })({ text: 'x' }), /^Error: Not sent: connect Feishu first/) ++ await assert.rejects( ++ feishuReply({ secretValue: () => 'https://open.feishu.cn/open-apis/bot/v2/hook/abc', post: async () => ({ ok: false, error: 'feishu/http-400' }) })({ text: 'x' }), ++ /Not sent: Feishu refused the answer \(feishu\/http-400\)/, ++ ) ++}) ++ +test('a Composio address without https refuses to send the key', async (t) => { + const realFetch = globalThis.fetch + t.after(() => { globalThis.fetch = realFetch }) diff --git a/patches/ds-bot/files/src/host/feishu-inbound.js b/patches/ds-bot/files/src/host/feishu-inbound.js index dae641e2..3b53471c 100644 --- a/patches/ds-bot/files/src/host/feishu-inbound.js +++ b/patches/ds-bot/files/src/host/feishu-inbound.js @@ -84,6 +84,10 @@ export function createInbound({ } = {}) { const seen = new Set() const order = [] + // Events being handed over right now. Two copies of one event can arrive while the first is still + // in `deliver`; the ring is written only after that resolves (#1266 INC), so this is what keeps the + // second copy from starting a second turn in the gap. + const inFlight = new Set() const view = { state: enabled ? 'connecting' : 'off', connected: 0, duplicates: 0, refused: 0, ignored: 0 } let closer let starting @@ -129,13 +133,21 @@ export function createInbound({ log('a Feishu message from %s is not the owner; no turn started', read.openId) return 'refused' } - if (!remember(read.id)) { + if (seen.has(read.id) || inFlight.has(read.id)) { view.duplicates += 1 log('a Feishu event %s was already handled; duplicate ignored', read.id) return 'duplicate' } - const name = (await nameOf(read.openId)) ?? handleOf(read.chatId) - await deliver({ handle: handleOf(read.chatId), text: read.text, name }) + inFlight.add(read.id) + try { + const name = (await nameOf(read.openId)) ?? handleOf(read.chatId) + await deliver({ handle: handleOf(read.chatId), text: read.text, name }) + } finally { + inFlight.delete(read.id) + } + // Only now is the id handled: a `deliver` that threw leaves it out of the ring, so a replayed + // event is handed over again instead of being called a duplicate for ever (Reviewer, #1266). + remember(read.id) view.lastEventAt = new Date(now()).toISOString() return 'delivered' } diff --git a/patches/ds-bot/files/test/feishu-inbound.test.js b/patches/ds-bot/files/test/feishu-inbound.test.js index 30f2c5c7..9f9e56cc 100644 --- a/patches/ds-bot/files/test/feishu-inbound.test.js +++ b/patches/ds-bot/files/test/feishu-inbound.test.js @@ -218,3 +218,48 @@ test('a stop while the socket dials hangs up the one that arrives and never repo assert.equal(inbound.view().state, 'connecting', 'the row does not flip back to ready behind the owner') assert.equal(inbound.view().connected, 0) }) + +// #1266 INC (Reviewer): the ring used to be written *before* `deliver`, so a delivery that threw +// left the id in it and every replay read `duplicate` — a lost message the owner could never get +// back. The id now enters the ring only once the message has been handed to its chat. +test('a message whose delivery throws is handed over again, not remembered as handled (#1266)', async () => { + const turns = [] + let refuse = true + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport: { connect: async () => ({ close: async () => {} }) }, + deliver: async message => { + if (refuse) { + refuse = false + throw new Error('no chat seam is installed for a Feishu message') + } + turns.push(message) + }, + }) + await inbound.start() + await assert.rejects(inbound.handleEvent(event()), /no chat seam is installed/) + assert.equal(await inbound.handleEvent(event()), 'delivered', 'the same event_id is handled again') + assert.equal(turns.length, 1) + assert.equal(inbound.view().duplicates, 0, 'a failed delivery is not a duplicate') + assert.equal(inbound.seen(), 1, 'the ring holds the id once, after the delivery succeeded') +}) + +test('two copies of one event arriving together still start one turn (#1266)', async () => { + const turns = [] + let release + const handed = new Promise(resolve => { release = resolve }) + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport: { connect: async () => ({ close: async () => {} }) }, + deliver: async message => { await handed; turns.push(message) }, + }) + await inbound.start() + const first = inbound.handleEvent(event()) + const second = inbound.handleEvent(event()) + release() + const results = await Promise.all([first, second]) + assert.deepEqual(results.sort(), ['delivered', 'duplicate']) + assert.equal(turns.length, 1) +}) diff --git a/patches/ds-bot/files/test/remote-chat.test.js b/patches/ds-bot/files/test/remote-chat.test.js index 4f5902ed..39b95d85 100644 --- a/patches/ds-bot/files/test/remote-chat.test.js +++ b/patches/ds-bot/files/test/remote-chat.test.js @@ -2,6 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { remoteTurn, isRemoteChat, REMOTE_PROVIDER, install, notSentLine } from '../src/host/remote-chat.js'; import { isHomeLevelRemote, isListedRemoteChat, isShownRemoteChat } from '../src/shared/remote-label.js'; +import { feishuReply } from '../src/host/connectors.js'; const user = (content, source, id = 'm1') => ({ role: 'user', id, content, ...(source ? { source } : {}) }); @@ -282,3 +283,24 @@ test('a line Bot Social attests the friend typed themselves carries the person\' assert.equal(followups[0].source.form, 'remote-chat') assert.equal(followups[1].source.text, 'bot line') }) + +// #1266 INC (Reviewer): the whole exit of a Feishu answer, composed. A `feishu:` chat carries the +// feishu plugin, the plugin's sender is the one `connectors.js` registers, and a message typed into +// that chat leaves on the webhook — not as "Not sent: the plugin that reaches this Bot is not running." +test('a reply typed into a feishu chat leaves on the Feishu webhook (#1266)', async () => { + const state = { bots: { f1: { id: 'f1', name: '飞书 Owner', remote: { handle: 'feishu:oc_chat_1', plugin: 'feishu' } } } } + let adapter + const posts = [] + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: { llm: { registerAdapter: (_, a) => { adapter = a } } } } + install(rt) + rt.remoteChats.onSend('feishu', feishuReply({ + secretValue: name => (name === 'FEISHU_WEBHOOK' ? 'https://open.feishu.cn/open-apis/bot/v2/hook/abc' : undefined), + post: async (webhook, text) => { posts.push([webhook, text]); return { ok: true } }, + })) + let said = '' + for await (const event of adapter.stream({ model: 'feishu:oc_chat_1', messages: [{ role: 'user', id: 'm1', content: [{ type: 'text', text: 'the answer' }] }] })) { + if (event.type === 'text-delta') said += event.text + } + assert.equal(said, '', 'nothing is drawn for a send that left') + assert.deepEqual(posts, [['https://open.feishu.cn/open-apis/bot/v2/hook/abc', 'the answer']]) +})