diff --git a/changelog.d/1266-feishu-connector-row.changed.md b/changelog.d/1266-feishu-connector-row.changed.md new file mode 100644 index 00000000..c05bfe41 --- /dev/null +++ b/changelog.d/1266-feishu-connector-row.changed.md @@ -0,0 +1 @@ +Feishu (Lark) is now connected on Settings → Connectors (左下角「连接应用」), not through the Secrets page or a config file. The one Feishu row offers two modes — a custom-bot webhook (outbound only, as before) or a self-built app with its long connection — and renders one field per credential: the App ID is kept in the record because it is not a secret, the App Secret only in `secrets.json`. App mode proves the pair with one tenant token, opens the socket from the row, answers a message back into the same Feishu chat as the app, and offers `Choose Bots`; the row also carries **允许的飞书用户** — nobody by default, the sender who was turned away listed with Allow, and a field to paste an `open_id` — so a stranger's message never spends the owner's tokens. `Disconnect` closes the socket and clears both credentials. A replayed `event_id` still lands once, and a half-saved pair dials nothing and says so. The app pair is no longer read from `FEISHU_APP_ID` on the Secrets page, and `feishu` is now a reserved capability name. ([#1266](https://github.com/ThinkFlowLab/FlyBot/issues/1266), [#1292](https://github.com/ThinkFlowLab/FlyBot/issues/1292)) diff --git a/core/client.js b/core/client.js index 23298d0f..1905d180 100644 --- a/core/client.js +++ b/core/client.js @@ -321,6 +321,20 @@ var ZH = { "Posts into one Feishu group through a custom bot webhook": "通过自定义机器人 webhook 发到你的一个飞书群", "Feishu webhook URL": "飞书 webhook 地址", "Get a webhook URL in Feishu": "去飞书获取 webhook 地址", + "Custom bot webhook": "自定义机器人 webhook", + "App (long connection)": "自建应用(长连接)", + "Webhook URL": "Webhook 地址", + "App ID": "App ID", + "App Secret": "App Secret", + "Create a Feishu app": "去飞书创建自建应用", + "How to connect": "连接方式", + "Connected: app, over the long connection": "已连接:自建应用,走长连接", + "Connected: custom bot webhook": "已连接:自定义机器人 webhook", + "Who may talk to the Bots": "允许的飞书用户", + "Nobody yet. A message from anyone else starts no turn.": "还没有人。其他人发来的消息不会起 turn。", + "Paste an open_id to allow": "粘贴 open_id 来允许某个人", + "Feishu open_id": "飞书 open_id", + Block: "移除", "Message to send to Feishu": "要发到飞书的消息", "Type a message for the Feishu group": "输入要发到飞书群的消息", Send: "发送", @@ -12514,7 +12528,7 @@ function ownBots(roster) { const me = roster?.self?.memberId; return (roster?.bots ?? []).filter((bot) => !bot.remote && (me === void 0 || me === null || bot.ownerId === void 0 || bot.ownerId === me)); } -var connectedConnectors = (roster) => (roster?.connectors ?? []).filter((connector) => connector.state !== "off" && connector.kind !== "sender"); +var connectedConnectors = (roster) => (roster?.connectors ?? []).filter((connector) => connector.state !== "off" && connector.kind !== "sender" && !(connector.kind === "channel" && connector.mode !== "app")); var isOn = (bot, id) => bot.connectors?.[id] !== false; function ownHint(bot, id) { return isOn(bot, id) ? t("On. This Bot can use it on every turn.") : t("Off. This Bot will not use it."); @@ -15935,10 +15949,81 @@ var ABOUT = { function statusText2(connector) { if (connector.state === "connecting") return t("Connecting…"); if (connector.state === "error") return t("Could not connect: {error}", { error: connector.error ?? "" }); + if (connector.state === "ready" && connector.kind === "channel") { + return connector.mode === "app" ? t("Connected: app, over the long connection") : t("Connected: custom bot webhook"); + } if (connector.state === "ready" && connector.apps?.length > 0) return t("Connected: {apps}", { apps: connector.apps.map(appName).join(", ") }); if (connector.state === "ready") return connector.account ? t("Connected as {account}", { account: connector.account }) : t("Connected"); return ABOUT[connector.id]?.() ?? ""; } +function CredentialField({ credential, value, onChange, onEnter, autoFocus }) { + const label = t(credential.label); + if (credential.masked) return (0, import_react37.createElement)(MaskedInput, { value, onChange, onEnter, label, autoFocus }); + return (0, import_react37.createElement)( + "label", + { className: "bt-q-field" }, + (0, import_react37.createElement)("input", { + className: "bt-input", + type: "text", + value, + "aria-label": label, + placeholder: label, + spellCheck: false, + autoFocus, + onChange: (event) => onChange(event.target.value), + onKeyDown: (event) => { + if (event.key === "Enter") onEnter?.(); + } + }) + ); +} +function AllowedSenders({ connector, actions, run, busy }) { + const [openId, setOpenId] = (0, import_react37.useState)(""); + const allowed = connector.allowed ?? []; + const refused = connector.inbound?.refusedSenders ?? []; + const allow = (id) => void run(actions.connectorAllow(connector.id, id)); + return (0, import_react37.createElement)( + "div", + { className: "bt-connector-allowed" }, + (0, import_react37.createElement)("span", { className: "bt-connector-person-title" }, t("Who may talk to the Bots")), + allowed.length === 0 && refused.length === 0 ? (0, import_react37.createElement)("span", { className: "bt-set-hint" }, t("Nobody yet. A message from anyone else starts no turn.")) : null, + allowed.map((entry) => (0, import_react37.createElement)( + "div", + { key: entry.openId, className: "bt-connector-person" }, + (0, import_react37.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId), + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorBlock(connector.id, entry.openId)) }, t("Block")) + )), + refused.map((entry) => (0, import_react37.createElement)( + "div", + { key: entry.openId, className: "bt-connector-person" }, + (0, import_react37.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId), + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => allow(entry.openId) }, t("Allow")) + )), + (0, import_react37.createElement)( + "div", + { className: "bt-connector-form-row" }, + (0, import_react37.createElement)("input", { + className: "bt-input", + type: "text", + value: openId, + spellCheck: false, + "aria-label": t("Feishu open_id"), + placeholder: t("Paste an open_id to allow"), + onChange: (event) => setOpenId(event.target.value), + onKeyDown: (event) => { + if (event.key === "Enter" && openId.trim() !== "") { + allow(openId.trim()); + setOpenId(""); + } + } + }), + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || openId.trim() === "", onClick: () => { + allow(openId.trim()); + setOpenId(""); + } }, t("Allow")) + ) + ); +} var APP_STATUS = { ready: () => t("Ready"), "sign-in": () => t("Needs sign-in") }; function AppChip({ name, status, onRemove }) { const label = APP_STATUS[status]?.(); @@ -16021,6 +16106,14 @@ function ConnectorRow({ connector, roster, actions }) { }; const gateway = connector.id === "composio"; const sender = connector.kind === "sender"; + const channel = connector.kind === "channel"; + const modes = connector.modes ?? []; + const [mode, setMode] = (0, import_react37.useState)(() => connector.mode ?? modes[0]?.id); + const [values, setValues] = (0, import_react37.useState)({}); + const chosen = modes.find((entry) => entry.id === mode) ?? modes[0]; + const field = (name) => values[name] ?? ""; + const setField = (name, value) => setValues((current) => ({ ...current, [name]: value })); + const filled = chosen !== void 0 && chosen.credentials.every((credential) => field(credential.name).trim() !== ""); const [statuses, setStatuses] = (0, import_react37.useState)({}); const appsKey = (connector.apps ?? []).join(","); (0, import_react37.useEffect)(() => { @@ -16040,7 +16133,17 @@ function ConnectorRow({ connector, roster, actions }) { }, [gateway, connector.state, appsKey, picking]); const search = (0, import_react37.useCallback)((query) => actions.connectorAppSearch(connector.id, query, connector.state === "off" ? token : void 0), [connector.id, connector.state, token]); const connect = () => { - if (busy || token.trim() === "") return; + if (busy) return; + if (channel) { + if (chosen === void 0 || !filled) return; + const credentials = Object.fromEntries(chosen.credentials.map((credential) => [credential.name, field(credential.name)])); + void run(actions.connectorConnect(connector.id, { mode: chosen.id, credentials }), () => { + setValues({}); + setForm(false); + }); + return; + } + if (token.trim() === "") return; if (gateway && apps.length === 0) { setError(t("Pick at least one app")); return; @@ -16059,9 +16162,11 @@ function ConnectorRow({ connector, roster, actions }) { void run(actions.connectorApps(connector.id, apps), () => setPicking(false)); }; const on = connector.state !== "off"; + const canPost = sender || channel && connector.mode === "webhook"; + const perBot = !sender && (!channel || connector.mode === "app"); const Mark = MARKS[connector.id]; const row = (0, import_react37.useRef)(null); - useMorph(row, `${form}:${picking}:${bots}:${error !== null}:${connector.state}`); + useMorph(row, `${form}:${picking}:${bots}:${error !== null}:${connector.state}:${mode}`); return (0, import_react37.createElement)( "div", { ref: row, className: "bt-connector", "data-state": connector.state }, @@ -16083,14 +16188,15 @@ function ConnectorRow({ connector, roster, actions }) { setApps(connector.apps ?? []); setPicking(true); } }, t("Choose apps")) : null, - on && !sender ? (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-expanded": bots, onClick: () => setBots((value) => !value) }, bots ? t("Done") : t("Choose Bots")) : null, + on && perBot ? (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-expanded": bots, onClick: () => setBots((value) => !value) }, bots ? t("Done") : t("Choose Bots")) : null, on ? (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorDisconnect(connector.id)) }, t("Disconnect")) : form ? null : (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setForm(true) }, t("Connect")) ) ), on && gateway && !picking ? (0, import_react37.createElement)(AppStatusList, { apps: connector.apps ?? [], statuses }) : null, - on && !sender ? (0, import_react37.createElement)("span", { className: "bt-connector-uses" }, usageText(roster, connector.id)) : null, + on && perBot ? (0, import_react37.createElement)("span", { className: "bt-connector-uses" }, usageText(roster, connector.id)) : null, on && bots ? (0, import_react37.createElement)(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null, - on && sender ? (0, import_react37.createElement)( + on && channel && connector.mode === "app" ? (0, import_react37.createElement)(AllowedSenders, { connector, actions, run, busy }) : null, + on && canPost ? (0, import_react37.createElement)( "div", { className: "bt-connector-form" }, (0, import_react37.createElement)("label", { className: "bt-q-field" }, (0, import_react37.createElement)("input", { className: "bt-input", type: "text", value: message, "aria-label": t("Message to send to Feishu"), placeholder: t("Type a message for the Feishu group"), onChange: (event) => setMessage(event.target.value) })), @@ -16104,18 +16210,41 @@ function ConnectorRow({ connector, roster, actions }) { !on && form ? (0, import_react37.createElement)( "div", { className: "bt-connector-form" }, - (0, import_react37.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react37.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: sender ? t("Feishu webhook URL") : gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })), + channel ? (0, import_react37.createElement)("div", { className: "bt-states", role: "group", "aria-label": t("How to connect") }, modes.map((entry) => (0, import_react37.createElement)("button", { + key: entry.id, + type: "button", + className: "bt-state", + "aria-pressed": entry.id === chosen?.id, + onClick: () => { + setMode(entry.id); + setValues({}); + setError(null); + } + }, t(entry.label)))) : null, + channel ? (chosen?.credentials ?? []).map((credential, index) => (0, import_react37.createElement)(CredentialField, { + key: credential.name, + credential, + value: field(credential.name), + onChange: (value) => setField(credential.name, value), + onEnter: connect, + autoFocus: index === 0 + })) : (0, import_react37.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react37.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })), gateway ? (0, import_react37.createElement)(AppPicker, { apps, onChange: setApps, search: token.trim() === "" ? void 0 : search }) : null, (0, import_react37.createElement)( "div", { className: "bt-connector-form-row" }, - (0, import_react37.createElement)("a", { className: "bt-connector-link", href: sender ? FEISHU_WEBHOOK_URL : gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, sender ? t("Get a webhook URL in Feishu") : gateway ? t("Get a Composio API key") : t("Create a token on GitHub")), + (0, import_react37.createElement)( + "a", + { className: "bt-connector-link", href: channel ? chosen?.link ?? FEISHU_WEBHOOK_URL : gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, + channel ? t(chosen?.linkLabel ?? "Get a webhook URL in Feishu") : gateway ? t("Get a Composio API key") : t("Create a token on GitHub") + ), (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { setForm(false); setToken(""); + setValues({}); setError(null); } }, t("Cancel")), - (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || token.trim() === "", onClick: connect }, busy ? t("Connecting…") : t("Connect")) + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || (channel ? !filled : token.trim() === ""), onClick: connect }, busy ? t("Connecting…") : t("Connect")) ) ) : null, on && gateway && picking ? (0, import_react37.createElement)( @@ -20038,10 +20167,14 @@ function apply(ctx) { secretCancel: (requestId) => after(call("secret-cancel", { requestId })), secretUpdate: (name, scope) => after(call("secret-update", { name, scope })), secretDelete: (name) => after(call("secret-delete", { name })), - connectorConnect: (id, token, apps) => after(call("connector-connect", { id, token, ...apps !== void 0 ? { apps } : {} })), + // A channel's form sends { mode, credentials }, one field per credential; every other connector + // sends its one token. Both go out under the same endpoint. + connectorConnect: (id, token, apps) => after(call("connector-connect", typeof token === "object" && token !== null ? { id, ...token, ...apps !== void 0 ? { apps } : {} } : { id, token, ...apps !== void 0 ? { apps } : {} })), connectorApps: (id, apps) => after(call("connector-apps", { id, apps })), connectorAppSearch: (id, query, token) => call("connector-app-search", { id, query, ...token ? { token } : {} }), connectorAppStatus: (id) => call("connector-app-status", { id }), + connectorAllow: (id, openId) => after(call("connector-allow", { id, openId })), + connectorBlock: (id, openId) => after(call("connector-block", { id, openId })), connectorDisconnect: (id) => after(call("connector-disconnect", { id })), connectorRetry: (id) => after(call("connector-retry", { id })), connectorSend: (id, text) => after(call("connector-send", { id, text })), diff --git a/core/src/client/connector-bots.js b/core/src/client/connector-bots.js index 27fd56ed..068a6b8a 100644 --- a/core/src/client/connector-bots.js +++ b/core/src/client/connector-bots.js @@ -12,9 +12,12 @@ export function ownBots(roster) { return (roster?.bots ?? []).filter(bot => !bot.remote && (me === undefined || me === null || bot.ownerId === undefined || bot.ownerId === me)) } -// Connectors that give a Bot tools. A sender (#1263) has none: a per-Bot switch for it would be a -// choice that never changes anything, so it is not offered. -export const connectedConnectors = roster => (roster?.connectors ?? []).filter(connector => connector.state !== 'off' && connector.kind !== 'sender') +// Connectors that give a Bot tools or a way in. A sender (#1263) has neither: a per-Bot switch for it +// would be a choice that never changes anything, so it is not offered. A channel (#1266) offers the +// switch only in app mode: a webhook-only Feishu row is today's sender and has nothing to share. +export const connectedConnectors = roster => (roster?.connectors ?? []).filter(connector => connector.state !== 'off' + && connector.kind !== 'sender' + && !(connector.kind === 'channel' && connector.mode !== 'app')) // Only an explicit false turns it off; no record means on. export const isOn = (bot, id) => bot.connectors?.[id] !== false diff --git a/core/src/client/connectors.js b/core/src/client/connectors.js index 7d256412..0de3db10 100644 --- a/core/src/client/connectors.js +++ b/core/src/client/connectors.js @@ -41,11 +41,54 @@ const ABOUT = { function statusText(connector) { if (connector.state === 'connecting') return t('Connecting…') if (connector.state === 'error') return t('Could not connect: {error}', { error: connector.error ?? '' }) + if (connector.state === 'ready' && connector.kind === 'channel') { + return connector.mode === 'app' ? t('Connected: app, over the long connection') : t('Connected: custom bot webhook') + } if (connector.state === 'ready' && connector.apps?.length > 0) return t('Connected: {apps}', { apps: connector.apps.map(appName).join(', ') }) if (connector.state === 'ready') return connector.account ? t('Connected as {account}', { account: connector.account }) : t('Connected') return ABOUT[connector.id]?.() ?? '' } +// One credential of the form (#1266): the definition says which fields a mode needs and whether a +// field is masked. A masked one is the same field the Secrets page uses, so it never comes back. +function CredentialField({ credential, value, onChange, onEnter, autoFocus }) { + const label = t(credential.label) + if (credential.masked) return h(MaskedInput, { value, onChange, onEnter, label, autoFocus }) + return h('label', { className: 'bt-q-field' }, + h('input', { + className: 'bt-input', type: 'text', value, 'aria-label': label, placeholder: label, spellCheck: false, autoFocus, + onChange: event => onChange(event.target.value), + onKeyDown: event => { if (event.key === 'Enter') onEnter?.() }, + })) +} + +// Who may talk to the Bots (#1266). Nobody until the owner allows someone: a stranger who messages +// is one line here with Allow beside it, and the open_id can be pasted before anyone has written. +function AllowedSenders({ connector, actions, run, busy }) { + const [openId, setOpenId] = useState('') + const allowed = connector.allowed ?? [] + const refused = connector.inbound?.refusedSenders ?? [] + const allow = id => void run(actions.connectorAllow(connector.id, id)) + return h('div', { className: 'bt-connector-allowed' }, + h('span', { className: 'bt-connector-person-title' }, t('Who may talk to the Bots')), + allowed.length === 0 && refused.length === 0 + ? h('span', { className: 'bt-set-hint' }, t('Nobody yet. A message from anyone else starts no turn.')) + : null, + allowed.map(entry => h('div', { key: entry.openId, className: 'bt-connector-person' }, + h('span', { className: 'bt-connector-person-name' }, entry.name ?? entry.openId), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => void run(actions.connectorBlock(connector.id, entry.openId)) }, t('Block')))), + refused.map(entry => h('div', { key: entry.openId, className: 'bt-connector-person' }, + h('span', { className: 'bt-connector-person-name' }, entry.name ?? entry.openId), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => allow(entry.openId) }, t('Allow')))), + h('div', { className: 'bt-connector-form-row' }, + h('input', { + className: 'bt-input', type: 'text', value: openId, spellCheck: false, 'aria-label': t('Feishu open_id'), placeholder: t('Paste an open_id to allow'), + onChange: event => setOpenId(event.target.value), + onKeyDown: event => { if (event.key === 'Enter' && openId.trim() !== '') { allow(openId.trim()); setOpenId('') } }, + }), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy || openId.trim() === '', onClick: () => { allow(openId.trim()); setOpenId('') } }, t('Allow')))) +} + // What a picked app needs before a Bot can use it, as Composio last answered. const APP_STATUS = { ready: () => t('Ready'), 'sign-in': () => t('Needs sign-in') } @@ -122,6 +165,17 @@ function ConnectorRow({ connector, roster, actions }) { const gateway = connector.id === 'composio' // A sender posts through one webhook: no tools, so no per-Bot switch, and its own send field. const sender = connector.kind === 'sender' + // A channel (#1266) is one row with two modes: a custom-bot webhook (outbound only, today's + // behaviour) or a self-built app (the long connection in, answers out). The form is the mode's + // credential list, rendered field by field. + const channel = connector.kind === 'channel' + const modes = connector.modes ?? [] + const [mode, setMode] = useState(() => connector.mode ?? modes[0]?.id) + const [values, setValues] = useState({}) + const chosen = modes.find(entry => entry.id === mode) ?? modes[0] + const field = name => values[name] ?? '' + const setField = (name, value) => setValues(current => ({ ...current, [name]: value })) + const filled = chosen !== undefined && chosen.credentials.every(credential => field(credential.name).trim() !== '') const [statuses, setStatuses] = useState({}) const appsKey = (connector.apps ?? []).join(',') // Asks Composio which picked apps are signed in, when the row is ready and again when the list changes. @@ -133,7 +187,14 @@ function ConnectorRow({ connector, roster, actions }) { }, [gateway, connector.state, appsKey, picking]) const search = useCallback(query => actions.connectorAppSearch(connector.id, query, connector.state === 'off' ? token : undefined), [connector.id, connector.state, token]) const connect = () => { - if (busy || token.trim() === '') return + if (busy) return + if (channel) { + if (chosen === undefined || !filled) return + const credentials = Object.fromEntries(chosen.credentials.map(credential => [credential.name, field(credential.name)])) + void run(actions.connectorConnect(connector.id, { mode: chosen.id, credentials }), () => { setValues({}); setForm(false) }) + return + } + if (token.trim() === '') return if (gateway && apps.length === 0) { setError(t('Pick at least one app')); return } void run(actions.connectorConnect(connector.id, token, gateway ? apps : undefined), () => { setToken(''); setForm(false) }) } @@ -143,9 +204,14 @@ function ConnectorRow({ connector, roster, actions }) { void run(actions.connectorApps(connector.id, apps), () => setPicking(false)) } const on = connector.state !== 'off' + // A channel offers Send only while its webhook mode is live: app mode answers by itself. + const canPost = sender || (channel && connector.mode === 'webhook') + // Choose Bots is for what gives a Bot tools or a way in. A webhook-only channel has neither, so the + // switch would change nothing (acceptance 5, #1266). + const perBot = !sender && (!channel || connector.mode === 'app') const Mark = MARKS[connector.id] const row = useRef(null) - useMorph(row, `${form}:${picking}:${bots}:${error !== null}:${connector.state}`) + useMorph(row, `${form}:${picking}:${bots}:${error !== null}:${connector.state}:${mode}`) return h('div', { ref: row, className: 'bt-connector', 'data-state': connector.state }, h('div', { className: 'bt-connector-head' }, h('span', { className: 'bt-connector-mark' }, Mark ? h(Mark) : null), @@ -155,25 +221,36 @@ function ConnectorRow({ connector, roster, actions }) { h('span', { className: 'bt-connector-actions' }, connector.state === 'error' ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => void run(actions.connectorRetry(connector.id)) }, t('Try again')) : null, on && gateway && !picking ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => { setApps(connector.apps ?? []); setPicking(true) } }, t('Choose apps')) : null, - on && !sender ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm', 'aria-expanded': bots, onClick: () => setBots(value => !value) }, bots ? t('Done') : t('Choose Bots')) : null, + on && perBot ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm', 'aria-expanded': bots, onClick: () => setBots(value => !value) }, bots ? t('Done') : t('Choose Bots')) : null, on ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => void run(actions.connectorDisconnect(connector.id)) }, t('Disconnect')) : form ? null : h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => setForm(true) }, t('Connect')))), on && gateway && !picking ? h(AppStatusList, { apps: connector.apps ?? [], statuses }) : null, - on && !sender ? h('span', { className: 'bt-connector-uses' }, usageText(roster, connector.id)) : null, + on && perBot ? h('span', { className: 'bt-connector-uses' }, usageText(roster, connector.id)) : null, on && bots ? h(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null, - on && sender ? h('div', { className: 'bt-connector-form' }, + on && channel && connector.mode === 'app' ? h(AllowedSenders, { connector, actions, run, busy }) : null, + on && canPost ? h('div', { className: 'bt-connector-form' }, h('label', { className: 'bt-q-field' }, h('input', { className: 'bt-input', type: 'text', value: message, 'aria-label': t('Message to send to Feishu'), placeholder: t('Type a message for the Feishu group'), onChange: event => setMessage(event.target.value) })), h('div', { className: 'bt-connector-form-row' }, h('span', { className: 'bt-connector-link' }), h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy || message.trim() === '' || actions.connectorSend === undefined, onClick: () => void run(actions.connectorSend(connector.id, message), () => setMessage('')) }, t('Send')))) : null, !on && form ? h('div', { className: 'bt-connector-form' }, - h('label', { className: 'bt-q-field bt-secret-field' }, h(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: sender ? t('Feishu webhook URL') : gateway ? t('Composio API key') : t('GitHub token'), autoFocus: true })), + channel ? h('div', { className: 'bt-states', role: 'group', 'aria-label': t('How to connect') }, modes.map(entry => + h('button', { + key: entry.id, type: 'button', className: 'bt-state', 'aria-pressed': entry.id === chosen?.id, + onClick: () => { setMode(entry.id); setValues({}); setError(null) }, + }, t(entry.label)))) : null, + channel + ? (chosen?.credentials ?? []).map((credential, index) => h(CredentialField, { + key: credential.name, credential, value: field(credential.name), onChange: value => setField(credential.name, value), onEnter: connect, autoFocus: index === 0, + })) + : h('label', { className: 'bt-q-field bt-secret-field' }, h(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t('Composio API key') : t('GitHub token'), autoFocus: true })), gateway ? h(AppPicker, { apps, onChange: setApps, search: token.trim() === '' ? undefined : search }) : null, h('div', { className: 'bt-connector-form-row' }, - h('a', { className: 'bt-connector-link', href: sender ? FEISHU_WEBHOOK_URL : gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: '_blank', rel: 'noopener noreferrer' }, sender ? t('Get a webhook URL in Feishu') : gateway ? t('Get a Composio API key') : t('Create a token on GitHub')), - h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => { setForm(false); setToken(''); setError(null) } }, t('Cancel')), - h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy || token.trim() === '', onClick: connect }, busy ? t('Connecting…') : t('Connect')))) : null, + h('a', { className: 'bt-connector-link', href: channel ? (chosen?.link ?? FEISHU_WEBHOOK_URL) : gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: '_blank', rel: 'noopener noreferrer' }, + channel ? t(chosen?.linkLabel ?? 'Get a webhook URL in Feishu') : gateway ? t('Get a Composio API key') : t('Create a token on GitHub')), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => { setForm(false); setToken(''); setValues({}); setError(null) } }, t('Cancel')), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy || (channel ? !filled : token.trim() === ''), onClick: connect }, busy ? t('Connecting…') : t('Connect')))) : null, on && gateway && picking ? h('div', { className: 'bt-connector-form' }, h(AppPicker, { apps, onChange: setApps, search, statuses }), h('span', { className: 'bt-set-hint' }, t('The first time a Bot uses an app, it shows a sign-in link in the chat.')), diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index b91668eb..8397b2a7 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -285,6 +285,20 @@ const ZH = { 'Posts into one Feishu group through a custom bot webhook': '通过自定义机器人 webhook 发到你的一个飞书群', 'Feishu webhook URL': '飞书 webhook 地址', 'Get a webhook URL in Feishu': '去飞书获取 webhook 地址', + 'Custom bot webhook': '自定义机器人 webhook', + 'App (long connection)': '自建应用(长连接)', + 'Webhook URL': 'Webhook 地址', + 'App ID': 'App ID', + 'App Secret': 'App Secret', + 'Create a Feishu app': '去飞书创建自建应用', + 'How to connect': '连接方式', + 'Connected: app, over the long connection': '已连接:自建应用,走长连接', + 'Connected: custom bot webhook': '已连接:自定义机器人 webhook', + 'Who may talk to the Bots': '允许的飞书用户', + 'Nobody yet. A message from anyone else starts no turn.': '还没有人。其他人发来的消息不会起 turn。', + 'Paste an open_id to allow': '粘贴 open_id 来允许某个人', + 'Feishu open_id': '飞书 open_id', + Block: '移除', 'Message to send to Feishu': '要发到飞书的消息', 'Type a message for the Feishu group': '输入要发到飞书群的消息', Send: '发送', diff --git a/core/src/client/index.js b/core/src/client/index.js index 069d0b04..f9242763 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -596,10 +596,16 @@ export function apply(ctx) { secretCancel: requestId => after(call('secret-cancel', { requestId })), secretUpdate: (name, scope) => after(call('secret-update', { name, scope })), secretDelete: name => after(call('secret-delete', { name })), - connectorConnect: (id, token, apps) => after(call('connector-connect', { id, token, ...(apps !== undefined ? { apps } : {}) })), + // A channel's form sends { mode, credentials }, one field per credential; every other connector + // sends its one token. Both go out under the same endpoint. + connectorConnect: (id, token, apps) => after(call('connector-connect', typeof token === 'object' && token !== null + ? { id, ...token, ...(apps !== undefined ? { apps } : {}) } + : { id, token, ...(apps !== undefined ? { apps } : {}) })), connectorApps: (id, apps) => after(call('connector-apps', { id, apps })), connectorAppSearch: (id, query, token) => call('connector-app-search', { id, query, ...(token ? { token } : {}) }), connectorAppStatus: id => call('connector-app-status', { id }), + connectorAllow: (id, openId) => after(call('connector-allow', { id, openId })), + connectorBlock: (id, openId) => after(call('connector-block', { id, openId })), connectorDisconnect: id => after(call('connector-disconnect', { id })), connectorRetry: id => after(call('connector-retry', { id })), connectorSend: (id, text) => after(call('connector-send', { id, text })), diff --git a/core/src/host/authz.js b/core/src/host/authz.js index e317a6de..d2cce743 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -25,7 +25,7 @@ const NO_ACTOR_ALLOWED = new Set(['auth-redeem', 'auth-whoami', 'auth-invite']) // is one of them: there is at most one, and it carries no owner. const SHARED_BLOCKED = new Set([ 'secret-set', 'secret-allow', 'secret-cancel', 'secret-update', 'secret-delete', - 'connector-connect', 'connector-apps', 'connector-app-search', 'connector-app-status', 'connector-disconnect', 'connector-retry', 'connector-send', 'bot-connectors', + 'connector-connect', 'connector-apps', 'connector-app-search', 'connector-app-status', 'connector-allow', 'connector-block', 'connector-disconnect', 'connector-retry', 'connector-send', 'bot-connectors', 'capability-save', 'capability-delete', 'capability-approve', 'folders', 'folder-pick', 'folder-list', 'browser-wait', 'browser-result', diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 9f4fabe0..96483b35 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -489,6 +489,8 @@ export function install(rt) { case 'connector-apps': case 'connector-app-search': case 'connector-app-status': + case 'connector-allow': + case 'connector-block': case 'connector-disconnect': case 'connector-retry': case 'connector-send': diff --git a/core/src/host/connectors.js b/core/src/host/connectors.js index 3753869b..634d5091 100644 --- a/core/src/host/connectors.js +++ b/core/src/host/connectors.js @@ -18,7 +18,7 @@ import { randomUUID } from 'node:crypto' import { createNotifier } from './notify.js' -import { appCredentials, createInbound } from './feishu-inbound.js' +import { APP_SECRET_SECRET, appCredentials, createInbound } from './feishu-inbound.js' import { createFeishuTransport } from './feishu-sdk.js' export const GITHUB = 'github' @@ -28,7 +28,7 @@ export const COMPOSIO_SECRET = 'COMPOSIO_API_KEY' export const FEISHU = 'feishu' export const FEISHU_SECRET = 'FEISHU_WEBHOOK' // Names a saved capability may not take, because the mount already uses them. -export const RESERVED_NAMES = [GITHUB, COMPOSIO] +export const RESERVED_NAMES = [GITHUB, COMPOSIO, FEISHU] const MCP_CLIENT = '@deepseek-ai/dsh-mcp-client' const GITHUB_DEFAULTS = { url: 'https://api.githubcopilot.com/mcp/', @@ -318,20 +318,118 @@ 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 }) => { +// The app-mode probe (#1266): a POST that proves the pair and is then thrown away. The long +// connection dials with its own credentials, so nothing here may leak the token into an error, a log +// line or the row; the answer is only "accepted" or one sentence. +const FEISHU_TOKEN_PATH = '/open-apis/auth/v3/tenant_access_token/internal' + +const feishuOrigin = (api) => { + try { + return new URL(String(api)).origin + } catch { + throw new Error('The Feishu API address is not a URL') + } +} + +// One tenant token, asked for at the moment it is needed (the probe, or one answer). It is never +// kept: the socket keeps its own, and an answer does not outlive its POST. +export async function feishuAppToken({ appId, appSecret }, { api = FEISHU_DEFAULTS.api, fetch: request = fetch, timeoutMs = CHECK_TIMEOUT_MS } = {}) { + let response + try { + response = await withDeadline(request(`${feishuOrigin(api)}${FEISHU_TOKEN_PATH}`, { + method: 'POST', + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ app_id: appId, app_secret: appSecret }), + redirect: 'error', + signal: AbortSignal.timeout(Math.max(1, timeoutMs)), + }), timeoutMs) + } catch { + throw new Error('Could not reach Feishu') + } + const body = await response.json().catch(() => ({})) + const code = Number(body?.code) + if (response.status === 401 || response.status === 403 || code === 10003 || code === 10014) throw new Error('Feishu did not accept this App ID and App Secret') + if (!response.ok) throw new Error(`Feishu answered ${response.status}`) + const token = body?.tenant_access_token + if (code !== 0 || typeof token !== 'string' || token === '') throw new Error('Feishu did not accept this App ID and App Secret') + return token +} + +// The connect step of app mode: prove the pair, then forget the token on purpose. +export async function feishuAppCheck(credentials, options = {}) { + await feishuAppToken(credentials, options) + return {} +} + +// One answer back into the chat the message came from, as the app itself: `im/v1/messages` addressed +// by chat id (the `oc_…` tail of the `feishu:oc_…` handle). This is what makes "connect the app and +// it works" true with no webhook saved — the pair the owner just pasted is the whole credential. +export async function feishuAppSend({ appId, appSecret, chatId, text }, { api = FEISHU_DEFAULTS.api, fetch: request = fetch, timeoutMs = SEND_TIMEOUT_MS } = {}) { + const token = await feishuAppToken({ appId, appSecret }, { api, fetch: request, timeoutMs }) + let response + try { + response = await withDeadline(request(`${feishuOrigin(api)}/open-apis/im/v1/messages?receive_id_type=chat_id`, { + method: 'POST', + headers: { 'content-type': 'application/json; charset=utf-8', authorization: `Bearer ${token}` }, + body: JSON.stringify({ receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text: String(text) }) }), + redirect: 'error', + signal: AbortSignal.timeout(Math.max(1, timeoutMs)), + }), timeoutMs) + } catch { + throw new Error('Not sent: could not reach Feishu') + } + const body = await response.json().catch(() => ({})) + const code = Number(body?.code) + if (response.ok && code === 0) return + if (response.status === 401 || response.status === 403 || code === 99991663) throw new Error('Not sent: Feishu refused the app token while sending the answer') + throw new Error(`Not sent: Feishu refused the answer (feishu/${Number.isFinite(code) ? code : response.status})`) +} + +// The way out for an answer to a Feishu message (#1266). App mode answers as the app, in the very +// chat the message came from; webhook mode answers on the webhook phase 1 already posts on, so a +// message in and an answer out stay one credential and one transport. Which one is read on every +// send, so switching modes needs no re-registration. A missing credential or a refusal is a sentence +// the chat shows ("Not sent: …"), never a silent drop: `remote-chat` prints exactly what is thrown. +export function feishuReply({ secretValue, appOf, post = feishuPost, sendApp = feishuAppSend, timeoutMs = SEND_TIMEOUT_MS } = {}) { + return async ({ text, handle }) => { + const app = appOf?.() + if (app !== undefined) { + const chatId = typeof handle === 'string' && handle.startsWith(`${FEISHU}:`) ? handle.slice(FEISHU.length + 1) : undefined + if (chatId === undefined || chatId === '') throw new Error('Not sent: this answer has no Feishu chat to go to.') + await sendApp({ ...app, chatId, text: String(text ?? '') }, { timeoutMs }) + return + } 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.') + if (webhook === undefined) throw new Error('Not sent: connect Feishu first, so the answer has a way out.') 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'})`) } } +// The two ways the Feishu row connects (#1266), as data the form renders: one field per credential, +// `masked` on the ones that never come back to the client. There is one Feishu row, not one per mode: +// two rows with the same name would be a puzzle, and the owner asked to click Connect in one place. +export const FEISHU_MODES = [ + { + id: 'webhook', + label: 'Custom bot webhook', + credentials: [{ name: 'webhook', label: 'Webhook URL', masked: true }], + link: 'https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot', + linkLabel: 'Get a webhook URL in Feishu', + }, + { + id: 'app', + label: 'App (long connection)', + credentials: [ + { name: 'app_id', label: 'App ID', masked: false }, + { name: 'app_secret', label: 'App Secret', masked: true }, + ], + link: 'https://open.feishu.cn/document/home/develop-a-bot-in-5-minutes/create-an-app', + linkLabel: 'Create a Feishu app', + }, +] + const DEFINITIONS = { [GITHUB]: { id: GITHUB, @@ -388,10 +486,11 @@ const DEFINITIONS = { // Keeps the user id: a new key for the same FlyBot finds the apps already signed in. afterDisconnect: saved => saved?.userId === undefined ? undefined : { userId: saved.userId }, }, - // Feishu (Lark) is not an MCP server: a custom-bot webhook is one HTTPS POST into the instance's - // own group. So it is a *sender* — nothing is mounted, no Bot's tool list changes, and the only - // things it has are a status and a send (#1263). The app-credential path slots in behind the same - // send, without touching a caller. + // Feishu (Lark) is not an MCP server: it is a *channel* (#1266). Nothing is mounted and no Bot's + // tool list changes; what it has is a status, one way in (the long connection of a self-built app) + // and one way out (that app, or the custom-bot webhook of phase 1). `kind: 'channel'` is what makes + // the row offer Choose Bots and what makes `reconcile()` open the socket instead of importing a + // client: webhook mode is the same row with nothing to dial. [FEISHU]: { id: FEISHU, name: 'Feishu', @@ -399,8 +498,9 @@ const DEFINITIONS = { purpose: 'Feishu webhook', defaults: FEISHU_DEFAULTS, addresses: options => [options.api], - kind: 'sender', - paste: 'Paste a webhook URL first', + kind: 'channel', + modes: FEISHU_MODES, + paste: 'Choose how to connect Feishu and fill in its fields first', async check({ token, options }) { return feishuCheck(token, { api: options.api }) }, @@ -432,23 +532,31 @@ 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 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. + // below depends on which client dials. Off unless the owner connected the app on the row, 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 answer leaves through the + // sender this module registers in that same seam: app mode answers as the app, webhook mode on the + // webhook phase 1 posts on. One path in, one path out. // + // What the owner set lives in the connector record, not in `rt.config` (#1266): the row is the + // place Feishu is configured, so the switch and the allowed senders are read from the record every + // time instead of being copied while this module installs. + const saved = c => state.connectors?.[c.def.id] + const feishuRecord = () => state.connectors?.[FEISHU] + const appOf = () => appCredentials(feishuRecord(), name => rt.secretValue(name)) + // 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)), + credentials: () => appOf(), 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 + // registers the Feishu exit 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 = () => { @@ -456,12 +564,15 @@ export function install(rt) { 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) })) } + feishuWire = { seam, off: seam.onSend(FEISHU, feishuReply({ secretValue: name => rt.secretValue(name), appOf })) } } const feishuInbound = createInbound({ - enabled: rt.config?.feishu?.enabled === true, + // "On" means app mode with both halves saved. A half-saved pair dials nothing, and an event that + // arrives while it is off starts no turn, whatever adapter is still handing events over. + enabled: () => feishuRecord()?.mode === 'app' && appOf() !== undefined, ownerOpenId: rt.config?.feishu?.ownerOpenId, + allowed: () => feishuRecord()?.allowed ?? [], transport: () => rt.feishuTransport, deliver: async ({ handle, text, name }) => { const seam = rt.remoteChats @@ -479,7 +590,6 @@ export function install(rt) { redact: rt.redactSecrets, }) - const saved = c => state.connectors?.[c.def.id] const unmount = async (c) => { const fiber = c.mount?.fiber c.mount = null @@ -487,8 +597,9 @@ export function install(rt) { } // 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({ + // sender, reading the saved webhook when it is called, so a reconnect needs no rebuild. Feishu is a + // channel now, but its webhook mode still sends: the notifier is per definition that has a `post`. + const notifiers = new Map(connectors.filter(c => typeof c.def.post === 'function').map(c => [c.def.id, createNotifier({ channel: c.def.id, log: (format, ...args) => rt.warn(`ds-bot: ${format}`, ...args), transport: { @@ -522,10 +633,72 @@ export function install(rt) { return rt.redactSecrets(text || 'error').slice(0, 300) } + // A channel is the row's life, not a mount (#1266): webhook mode is ready once its URL is saved, + // app mode is ready once its socket is open, and the socket's own lifecycle is the status. Keyed by + // what the dial depends on, so a new pair (or a mode switch) closes the old socket and opens one. + async function reconcileChannel(c, force, token) { + const { def } = c + const record = saved(c) ?? {} + const mode = record.mode === 'app' ? 'app' : record.mode === 'webhook' ? 'webhook' : undefined + const app = mode === 'app' ? appOf() : undefined + const key = mode === 'app' ? `app\n${app?.appId ?? ''}\n${app?.appSecret ?? ''}` : mode === 'webhook' ? `webhook\n${token ?? ''}` : undefined + if (!force && c.mount !== null && c.mount.key === key) return + await unmount(c) + // A changed pair (or a mode switch) is a new socket: the old one is hung up before the new one + // dials, so one record never holds two long connections. + await feishuInbound.stop() + if (mode === undefined) { + notifierOf(c)?.setEnabled(false) + await feishuInbound.stop() + c.status = { state: 'off' } + c.mount = { key } + return + } + if (c.insecure) { + c.mount = { key } + c.status = { state: 'error', error: INSECURE(def.name) } + return + } + // App mode answers as the app, webhook mode on the webhook: a saved webhook is worth a manual + // Send in either mode, and nothing else turns the notifier on. + notifierOf(c)?.setEnabled(token !== undefined) + if (mode === 'webhook') { + if (token === undefined) { + await feishuInbound.stop() + c.status = { state: 'off' } + c.mount = { key } + return + } + c.status = { state: 'connecting' } + // A start-up (or a secret change) only reports that a webhook is saved — a restart must not post + // to the group again. `force` is a person pressing Try again, which is that person asking for it. + if (force) await probe(c, token) + else c.status = { state: 'ready' } + c.mount = { key } + return + } + if (app === undefined) { + await feishuInbound.stop() + c.status = { state: 'error', error: 'A Feishu App ID and App Secret are both needed before the long connection can dial.' } + c.mount = { key } + return + } + c.status = { state: 'connecting' } + const result = await feishuInbound.start() + const inbound = feishuInbound.view() + c.status = result === 'ready' + ? { state: 'ready' } + : result === 'off' + ? { state: 'off' } + : { state: 'error', error: inbound.error ?? 'the Feishu long connection did not open' } + c.mount = { key } + } + async function reconcile(c, force) { const { def, options } = c if (c.saving > 0 && !force) return const token = options !== null && !stopped ? rt.secretValue(def.secret) : undefined + if (def.kind === 'channel') return reconcileChannel(c, force, token) const key = token === undefined ? undefined : `${token}\n${def.mountKey(saved(c))}` if (!force && c.mount !== null && c.mount.key === key) return await unmount(c) @@ -540,16 +713,6 @@ export function install(rt) { 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 - // again. `force` is a person pressing Try again, which is that person asking for the test. - if (def.kind === 'sender') { - notifierOf(c)?.setEnabled(true) - if (force) await probe(c, token) - else c.status = { state: 'ready' } - c.mount = { key } - return - } const loader = ctx.get?.('loader') if (typeof loader?.import !== 'function' || typeof ctx.plugin !== 'function') { c.mount = { key } @@ -602,7 +765,15 @@ 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' } } : {}), + // #1266: one row, two modes. `mode` is which one is live (null before the first connect), `modes` + // is what the form renders, and `allowed` is the guest list the app mode's gate reads. Nothing + // here carries a secret: the App ID is not one, and the webhook never comes back. + ...(c.def.id === FEISHU ? { + mode: saved(c)?.mode ?? null, + modes: c.def.modes, + allowed: (saved(c)?.allowed ?? []).map(entry => ({ openId: entry.openId, name: entry.name ?? entry.openId })), + inbound: { ...feishuInbound.view(), app: appOf() === undefined ? 'missing' : 'set' }, + } : {}), }) const connectorsView = () => connectors.filter(c => c.options !== null).map(view) @@ -623,29 +794,92 @@ export function install(rt) { await Promise.resolve(c.def.discard?.({ keys: [...new Set(keys.filter(key => key !== undefined))], options: c.options, session: gone })).catch(() => { /* never blocks the change */ }) } + // The Feishu row's own connect (#1266): one row, two modes. `credentials` is one field per + // credential the picked mode declares — the App ID is kept in the record because it is not a + // secret, the App Secret goes only to secrets.js. App mode proves the pair with one tenant token, + // then `sync` opens the socket; webhook mode proves the URL with one test message, as phase 1 did. + const connectChannel = async (c, body) => { + // A phase-1 client sends one `token` and no mode: that is the webhook, unchanged (#1266). + const mode = body.mode === 'app' ? 'app' + : body.mode === 'webhook' || (body.mode === undefined && typeof body.token === 'string' && body.token.trim() !== '') ? 'webhook' + : undefined + if (mode === undefined) throw new Error('Choose how to connect Feishu first') + if (c.insecure) throw new Error(INSECURE(c.def.name)) + await rt.loadSecrets() + const given = body.credentials !== null && typeof body.credentials === 'object' && body.credentials !== undefined ? body.credentials : {} + const text = name => (typeof given[name] === 'string' ? given[name].trim() : '') + if (mode === 'webhook') { + const webhook = text('webhook') !== '' ? text('webhook') : (typeof body.token === 'string' ? body.token.trim() : '') + if (webhook === '') throw new Error('Paste the webhook URL Feishu gave you') + const previous = saved(c) ?? {} + c.saving += 1 + try { + await rt.secretEndpoint('secret-set', { name: FEISHU_SECRET, value: webhook, purpose: c.def.purpose }) + state.connectors = { ...state.connectors, [FEISHU]: { ...previous, mode: 'webhook', connectedAt: Date.now() } } + await rt.save() + } finally { + c.saving -= 1 + } + // Phase 1's connect, unchanged: the webhook is saved first and then proved with one test + // message, so a refusal leaves the row in `error` with Try again beside it instead of losing + // what the owner pasted (acceptance 5, #1266). + await feishuInbound.stop() + await probe(c, webhook) + c.mount = { key: `webhook\n${webhook}` } + notifierOf(c)?.setEnabled(true) + return connectorsView() + } + const appId = text('app_id') + const appSecret = text('app_secret') + if (appId === '' || appSecret === '') throw new Error('Paste the App ID and App Secret Feishu gave the app') + await feishuAppCheck({ appId, appSecret }, { api: c.options.api }) + c.saving += 1 + try { + await rt.secretEndpoint('secret-set', { name: APP_SECRET_SECRET, value: appSecret, purpose: 'Feishu app secret' }) + // The guest list survives a re-pair: connecting again is not a decision to let strangers in. + const previous = saved(c) ?? {} + state.connectors = { ...state.connectors, [FEISHU]: { ...previous, mode: 'app', appId, connectedAt: Date.now() } } + await rt.save() + } finally { + c.saving -= 1 + } + await sync(c) + return connectorsView() + } + // The guest list, written from the row (#1266): Allow adds one open_id, Block takes it away. Who + // may talk is read by the inbound gate on every message, so this needs no restart — the next + // message from that sender is treated as the owner's. + const allowedOp = async (body, on) => { + const c = known(body.id) + if (c.def.kind !== 'channel') throw new Error('This connector has no allowed senders') + const openId = typeof body.openId === 'string' ? body.openId.trim() : '' + if (!/^[A-Za-z0-9_-]{1,128}$/.test(openId)) throw new Error('Paste the open_id of the sender to allow') + await rt.loadSecrets() + const record = saved(c) ?? {} + if (record.mode !== 'app') throw new Error('Connect Feishu as an app first') + const list = Array.isArray(record.allowed) ? record.allowed : [] + let next + if (on) { + const knownSender = (feishuInbound.view().refusedSenders ?? []).find(entry => entry.openId === openId) + next = list.some(entry => entry.openId === openId) ? list + : [...list, { openId, ...(knownSender?.name === undefined ? {} : { name: knownSender.name }) }] + } else { + next = list.filter(entry => entry.openId !== openId) + } + state.connectors = { ...state.connectors, [FEISHU]: { ...record, allowed: next } } + await rt.save() + return connectorsView() + } + const endpoints = { async 'connector-connect'(body) { const c = known(body.id) + // A channel has no single token: its form sends a mode and that mode's credential fields. + if (c.def.kind === 'channel') return connectChannel(c, body) const token = typeof body.token === 'string' ? body.token.trim() : '' if (token === '') throw new Error(c.def.paste ?? (c.def.id === GITHUB ? 'Paste a token first' : 'Paste a key first')) if (c.insecure) throw new Error(INSECURE(c.def.name)) await rt.loadSecrets() - // A sender has nothing to mount, so the webhook is saved first and then proved: a webhook - // Feishu refuses stays on the row as `error` with Try again beside it, the way a connector - // that fails its MCP mount does, instead of losing what the owner pasted. - if (c.def.kind === 'sender') { - c.saving += 1 - try { - await rt.secretEndpoint('secret-set', { name: c.def.secret, value: token, purpose: c.def.purpose }) - await keep(c, {}) - } finally { - c.saving -= 1 - } - await probe(c, token) - c.mount = { key: `${token}\n${c.def.mountKey(saved(c))}` } - notifierOf(c)?.setEnabled(true) - return connectorsView() - } const oldToken = rt.secretValue(c.def.secret) const previous = saved(c) const record = await c.def.check({ token, options: c.options, saved: previous, body }) @@ -715,9 +949,31 @@ export function install(rt) { throw new Error(reason(error)) } }, + async 'connector-allow'(body) { + return allowedOp(body, true) + }, + async 'connector-block'(body) { + return allowedOp(body, false) + }, async 'connector-disconnect'(body) { const c = known(body.id) await rt.loadSecrets() + if (c.def.kind === 'channel') { + // Disconnect closes both (#1266): the socket goes down, the webhook and the app secret are + // deleted (each only if it is there — deleting a key that is not saved is an error), and the + // record goes with them, so the row is `off` and no turn can start. + for (const name of [FEISHU_SECRET, APP_SECRET_SECRET]) { + if (rt.secretValue(name) !== undefined) await rt.secretEndpoint('secret-delete', { name }) + } + if (saved(c) !== undefined) { + delete state.connectors[c.def.id] + await rt.save() + } + await feishuInbound.stop() + await rt.forgetConnector?.(c.def.id) + await sync(c) + return connectorsView() + } const oldToken = rt.secretValue(c.def.secret) const previous = saved(c) if (oldToken !== undefined) await rt.secretEndpoint('secret-delete', { name: c.def.secret }) @@ -735,18 +991,18 @@ export function install(rt) { async 'connector-retry'(body) { const c = known(body.id) await rt.loadSecrets() + // "Try again" is one press for the whole row: a channel re-proves a webhook and dials the + // socket again (which is how an adapter installed after the first attempt comes up without a + // restart), and `reconcile` is the only place that dials, so one press is one socket. 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 - // decision the owner has not made (#1263). + // decision the owner has not made (#1263). Feishu's channel offers this only while a webhook is + // saved (today's behaviour, acceptance 5): app mode answers by itself, it has no room to post to. async 'connector-send'(body) { const c = known(body.id) - if (c.def.kind !== 'sender') throw new Error('This connector sends no messages') + if (typeof c.def.post !== 'function') throw new Error('This connector sends no messages') 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)) @@ -777,11 +1033,11 @@ export function install(rt) { ctx.effect(() => { stopped = false - // 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. + // The long connection dials once the secrets are loaded: the App Secret lives there, so dialling + // before that read would report a missing app that is in fact saved. `reconcile` opens it, so + // there is exactly one dial per start — a second call here would be two sockets on one record. 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 diff --git a/core/src/host/feishu-inbound.js b/core/src/host/feishu-inbound.js index 3b53471c..e953fa5d 100644 --- a/core/src/host/feishu-inbound.js +++ b/core/src/host/feishu-inbound.js @@ -47,19 +47,17 @@ export function readEvent(envelope) { } // 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`, +// connection needs an app, which is a pair in two different places (#1266): the App ID is not a +// secret, so it lives in the connector record and shows on the row; only the App Secret goes to +// secrets.js. Either half missing answers undefined, so a half-configured app dials nothing instead +// of failing at the handshake. The secret never leaves 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) +export function appCredentials(record, secretValue) { const asText = value => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined) - const appId = asText(id) - const appSecret = asText(secret) + const appId = asText(record?.appId) + const appSecret = asText(secretValue?.(APP_SECRET_SECRET)) if (appId === undefined || appSecret === undefined) return undefined return { appId, appSecret } } @@ -72,9 +70,17 @@ export const handleOf = chatId => `feishu:${chatId}` // `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`. +// A refused sender is kept so the connector row can show who was turned away and offer Allow. The +// list is bounded: a stranger who messages a hundred times is one row that keeps its last time, not +// an unbounded log in state.json. It lives in the view only — the connector decides what to save. +// `enabled` and `allowed` may each be a value or a function answering it: the row changes both while +// this object is already installed. +export const MAX_REFUSED = 20 + export function createInbound({ enabled = false, ownerOpenId, + allowed, transport, deliver, log = () => {}, @@ -88,7 +94,7 @@ export function createInbound({ // 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 } + const view = { state: (typeof enabled === 'function' ? enabled() === true : 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 @@ -106,14 +112,44 @@ export function createInbound({ 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) + // The switch and the guest list are read every time, not copied when the module is installed: the + // owner turns the connection on and allows a sender on the row, while this object is already made. + const on = () => (typeof enabled === 'function' ? enabled() === true : enabled === true) + + // Who may start a turn (#1266): the owner, plus whoever the row allowed. `` is the row's + // list — a list of open_ids, or of `{ openId }` entries — and empty means nobody, exactly as the + // owner-only gate did: connecting the app must never let a stranger spend the owner's tokens. + const allowedIds = () => { + const ids = new Set() + if (typeof ownerOpenId === 'string' && ownerOpenId !== '') ids.add(ownerOpenId) + const list = typeof allowed === 'function' ? allowed() : allowed + for (const entry of Array.isArray(list) ? list : []) { + const id = typeof entry === 'string' ? entry : entry?.openId + if (typeof id === 'string' && id !== '') ids.add(id) + } + return ids + } + + // One row per refused sender, newest last, bounded: a stranger who messages a hundred times is one + // line with a fresh time, not an unbounded log. A sender the row allows leaves the list at once. + const refused = new Map() + const refuse = (openId, name) => { + refused.delete(openId) + refused.set(openId, { openId, ...(name === undefined ? {} : { name }), at: new Date(now()).toISOString() }) + while (refused.size > MAX_REFUSED) refused.delete(refused.keys().next().value) + } + const refusedSenders = () => { + const ids = allowedIds() + return [...refused.values()].filter(entry => !ids.has(entry.openId)) + } async function nameOf(openId) { - if (typeof transport?.nameOf !== 'function') return undefined + // The transport may be a function (read again on every start), so the display name is asked of + // the adapter this call would dial, never of the option itself. + const socket = socketOf() + if (typeof socket?.nameOf !== 'function') return undefined try { - const name = await transport.nameOf(openId) + const name = await socket.nameOf(openId) return typeof name === 'string' && name.trim() !== '' ? name.trim() : undefined } catch { return undefined @@ -127,10 +163,18 @@ export function createInbound({ log('a Feishu event carried no readable text message; no turn started') return 'ignored' } - const expected = owner() - if (expected === undefined || read.openId !== expected) { + // Off is off: a socket that delivers while the owner has disconnected (a close that is still in + // flight, a stale adapter) starts nothing (#1266, acceptance 4). + if (!on()) { + view.refused += 1 + log('the Feishu long connection is off; no turn started') + return 'off' + } + if (!allowedIds().has(read.openId)) { view.refused += 1 - log('a Feishu message from %s is not the owner; no turn started', read.openId) + const name = await nameOf(read.openId) + refuse(read.openId, name) + log('a Feishu message from %s is not the owner or an allowed sender; no turn started', read.openId) return 'refused' } if (seen.has(read.id) || inFlight.has(read.id)) { @@ -155,7 +199,7 @@ export function createInbound({ // 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 (!on()) return Promise.resolve('off') if (starting !== undefined) return starting const socket = socketOf() // No adapter yet is a configuration state, not a failed dial: nothing is remembered, so a later @@ -201,10 +245,10 @@ export function createInbound({ starting = undefined const closing = closer closer = undefined - view.state = enabled ? 'connecting' : 'off' + view.state = on() ? '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 }) + 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, refusedSenders: refusedSenders() }) return { start, stop, handleEvent, view: viewOf, seen: () => seen.size } } diff --git a/core/test/connectors.test.js b/core/test/connectors.test.js index 36d7b486..d8ad10ce 100644 --- a/core/test/connectors.test.js +++ b/core/test/connectors.test.js @@ -418,32 +418,50 @@ test('deleting COMPOSIO_API_KEY on the Secrets page unmounts Composio, and GitHu 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' }) + assert.deepEqual(view.find(entry => entry.id === 'feishu').inbound, { state: 'off', connected: 0, duplicates: 0, refused: 0, refusedSenders: [], app: 'missing' }) }) +// #1266 (connector-row half): the app pair lives in the connector record and secrets.json, so a test +// renders the row a person would have filled in. The App ID is not a secret; only the secret is. +function appHome() { + const home = teamHome() + const state = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + state.connectors = { feishu: { mode: 'app', appId: 'cli_app_1', allowed: [{ openId: 'ou_owner_1' }] } } + writeFileSync(join(home, 'state.json'), JSON.stringify(state)) + writeFileSync(join(home, 'secrets.json'), JSON.stringify({ + version: 1, + secrets: [{ name: 'FEISHU_APP_SECRET', value: 'app-secret-value', purpose: 'Feishu app secret', scope: 'all', createdAt: 1, updatedAt: 1 }], + })) + return home +} + // #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 home = appHome() const ctx = fakeContext() capture(ctx) - const rt = createRuntime(ctx, { home, feishu: { enabled: true, ownerOpenId: 'ou_owner_1' } }) + const rt = createRuntime(ctx, { home }) t.after(async () => { await ctx.dispose(); await cleanHome(home) }) + const dials = [] 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. + // Boot dialled on its own: the pair is saved, so the row left `off` before this test installed any + // adapter. Whether it settles on `connecting` (a dial in flight) or `error` (a box with no SDK) + // depends on the host, so the assertion is that it is not `off` and that no stub dialled — the + // socket in flight is the host's own adapter, never the one installed below. const until = Date.now() + 2000 let boot = await inboundOf() - while (boot.error === undefined && Date.now() < until) { + while (boot.state === 'off' && 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)) + assert.equal(boot.app, 'set') + assert.notEqual(boot.state, 'off') + assert.equal(dials.length, 0, 'no stub was installed when boot dialled') - 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') @@ -465,10 +483,10 @@ const FEISHU_INBOUND_EVENT = { // 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 home = appHome() const ctx = fakeContext() capture(ctx) - const rt = createRuntime(ctx, { home, feishu: { enabled: true, ownerOpenId: 'ou_owner_1' } }) + const rt = createRuntime(ctx, { home }) t.after(async () => { await ctx.dispose(); await cleanHome(home) }) const dials = [] rt.feishuTransport = { connect: async (onEvent) => { dials.push(onEvent); return { close: async () => {} } } } diff --git a/core/test/feishu-channel.test.js b/core/test/feishu-channel.test.js new file mode 100644 index 00000000..2d147084 --- /dev/null +++ b/core/test/feishu-channel.test.js @@ -0,0 +1,258 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { RESERVED_NAMES, feishuAppSend, feishuReply } from '../src/host/connectors.js' +import { fakeContext } from './fake-ctx.js' + +// #1266, the connector-row half: the owner's word is 「飞书是做成一个连接器,左下角点击连接应用的时候 +// 可以连接」. So the row is where Feishu is configured — one row, two modes (a custom-bot webhook, or a +// self-built app with its long connection), the App ID kept in the record because it is not a secret, +// the App Secret only in secrets.json, Choose Bots for what a Bot can reach, and the guest list the +// app mode's gate reads. Every test here fails on the sender-row shape it replaces. + +const TEAM = { + version: 1, + mainBotIds: ['m1'], + revision: 1, + bots: { m1: { id: 'm1', sessionId: 'm1', name: 'Chief', role: 'Chief of Staff', instructions: '', createdAt: 1 } }, + rooms: {}, + exchanges: [], + exchangeTurns: {}, + questions: {}, +} + +const teamHome = record => { + const home = mkdtempSync(join(tmpdir(), 'dsb-feishu-channel-')) + const state = record === undefined ? TEAM : { ...TEAM, connectors: { feishu: record } } + writeFileSync(join(home, 'state.json'), JSON.stringify(state)) + return home +} + +const cleanHome = home => rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }) +const answer = (status, body) => ({ status, ok: status >= 200 && status < 300, body, json: async () => body }) + +// A fake Feishu: tenant tokens and message sends, and every call recorded. The scripted answers are +// consumed in order by non-token calls (the webhook probe, a send), the last one repeating. +function fakeFeishu(script = []) { + const calls = [] + const queue = [...script] + const fetch = async (url, init) => { + const body = init?.body === undefined ? undefined : JSON.parse(init.body) + calls.push({ url: String(url), method: init?.method, body, headers: init?.headers ?? {} }) + if (String(url).includes('/auth/v3/tenant_access_token/internal')) return answer(200, { code: 0, tenant_access_token: 't_app_token' }) + if (String(url).includes('/open-apis/im/v1/messages')) return answer(200, { code: 0, data: { message_id: 'om_answer' } }) + return queue.length > 1 ? queue.shift() : (queue[0] ?? answer(200, { code: 0, data: { message_id: 'om_1' } })) + } + return { calls, fetch } +} + +// The runtime a plugin sees after install, with the socket replaced by a stub: `dials` are the +// `onEvent` handlers handed to it, exactly what a real long connection would call. +function rig(t, { record, feishu = { api: 'https://feishu.test' } } = {}) { + const home = teamHome(record) + const ctx = fakeContext() + const register = ctx.connection.fetch.register + ctx.connection.fetch.register = (route) => { ctx.route = route; return register(route) } + const rt = createRuntime(ctx, { home, feishu }) + const dials = [] + const closes = [] + rt.feishuTransport = { + nameOf: async openId => (openId === 'ou_stranger_1' ? 'Mallory' : `Someone ${openId}`), + connect: async (onEvent) => { dials.push(onEvent); return { close: async () => { closes.push(1) } } }, + } + t.after(async () => { await ctx.dispose(); cleanHome(home) }) + return { rt, ctx, home, dials, closes } +} + +async function call(ctx, endpoint, payload) { + const response = await ctx.route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) })) + return response.json() +} + +const rowOf = value => (value?.connectors ?? value ?? []).find(entry => entry.id === 'feishu') +const until = async (read, done, ms = 3000) => { + const end = Date.now() + ms + let value = await read() + while (!done(value) && Date.now() < end) { + await new Promise(resolve => setTimeout(resolve, 10)) + value = await read() + } + return value +} +const event = ({ id = 'e1', openId = 'ou_owner_1', chatId = 'oc_chat_1', text = 'hello there' } = {}) => ({ + header: { event_id: id, event_type: 'im.message.receive_v1' }, + event: { + sender: { sender_id: { open_id: openId } }, + message: { chat_id: chatId, message_type: 'text', content: JSON.stringify({ text }) }, + }, +}) + +test('#1266: one Feishu row, two modes, nothing connected and no socket', async (t) => { + const { ctx, dials } = rig(t) + const row = rowOf((await call(ctx, 'state', {})).value) + assert.equal(row.kind, 'channel') + assert.equal(row.state, 'off') + assert.equal(row.mode, null, 'no mode is live before the first connect') + assert.deepEqual(row.modes.map(mode => mode.id), ['webhook', 'app']) + assert.deepEqual(row.modes[0].credentials.map(field => [field.name, field.masked]), [['webhook', true]]) + assert.deepEqual(row.modes[1].credentials.map(field => [field.name, field.masked]), [['app_id', false], ['app_secret', true]], 'the App ID is not a secret; the App Secret never comes back') + assert.deepEqual(row.allowed, []) + assert.deepEqual(row.inbound.refusedSenders, []) + assert.equal(row.inbound.state, 'off') + assert.equal(dials.length, 0, 'connecting is a decision, not a default') +}) + +test('#1266: connecting the app keeps the id in the record, the secret in secrets.json, and opens the socket', async (t) => { + const feishu = fakeFeishu() + const realFetch = globalThis.fetch + globalThis.fetch = feishu.fetch + const { rt, ctx, home, dials } = rig(t) + t.after(() => { globalThis.fetch = realFetch }) + + const connected = await call(ctx, 'connector-connect', { id: 'feishu', mode: 'app', credentials: { app_id: ' cli_app_1 ', app_secret: 'app-secret-value' } }) + assert.equal(connected.ok, true, JSON.stringify(connected)) + const row = rowOf(connected.value) + assert.equal(row.state, 'ready') + assert.equal(row.mode, 'app') + assert.equal(row.inbound.state, 'ready') + assert.equal(row.inbound.app, 'set') + assert.equal(dials.length, 1, 'the socket dialled once the pair was proved') + + const tokenCall = feishu.calls.find(entry => entry.url.endsWith('/open-apis/auth/v3/tenant_access_token/internal')) + assert.deepEqual(tokenCall.body, { app_id: 'cli_app_1', app_secret: 'app-secret-value' }, 'the probe proves the pair it was given') + assert.equal(feishu.calls.filter(entry => entry.url.includes('im/v1/messages')).length, 0, 'connecting sends no message') + + const disk = readFileSync(join(home, 'state.json'), 'utf8') + assert.equal(JSON.parse(disk).connectors.feishu.appId, 'cli_app_1') + assert.doesNotMatch(disk, /app-secret-value/, 'the App Secret is not in state.json') + assert.equal(rt.secretValue('FEISHU_APP_SECRET'), 'app-secret-value') + assert.doesNotMatch(JSON.stringify(connected.value), /app-secret-value/, 'and it never comes back in the row') +}) + +test('#1266: a stranger starts no turn, appears on the row, and Allow makes the next message land', async (t) => { + const feishu = fakeFeishu() + const realFetch = globalThis.fetch + globalThis.fetch = feishu.fetch + const { rt, ctx, dials } = rig(t) + t.after(() => { globalThis.fetch = realFetch }) + const delivered = [] + rt.remoteChats.open = async () => 'chat' + rt.remoteChats.deliver = async message => { delivered.push(message); return 'chat' } + + await call(ctx, 'connector-connect', { id: 'feishu', mode: 'app', credentials: { app_id: 'cli_app_1', app_secret: 'app-secret-value' } }) + const onEvent = dials[0] + assert.equal(await onEvent(event({ id: 'e1', openId: 'ou_stranger_1' })), 'refused') + assert.equal(delivered.length, 0, 'nobody may spend the owner\'s tokens by default') + + const refused = rowOf((await call(ctx, 'state', {})).value).inbound.refusedSenders + assert.equal(refused.length, 1) + assert.equal(refused[0].openId, 'ou_stranger_1') + assert.equal(refused[0].name, 'Mallory', 'the row names who was turned away') + assert.match(refused[0].at, /^\d{4}-\d{2}-\d{2}T/) + + const allowed = await call(ctx, 'connector-allow', { id: 'feishu', openId: 'ou_stranger_1' }) + assert.equal(allowed.ok, true, JSON.stringify(allowed)) + const row = rowOf(allowed.value) + assert.deepEqual(row.allowed, [{ openId: 'ou_stranger_1', name: 'Mallory' }]) + assert.deepEqual(row.inbound.refusedSenders, [], 'an allowed sender leaves the refused list at once') + + assert.equal(await onEvent(event({ id: 'e2', openId: 'ou_stranger_1' })), 'delivered') + assert.equal(delivered.length, 1) + assert.equal(delivered[0].handle, 'feishu:oc_chat_1') + assert.equal(delivered[0].plugin, 'feishu', 'the chat still carries the plugin its answers leave on') + + const blocked = await call(ctx, 'connector-block', { id: 'feishu', openId: 'ou_stranger_1' }) + assert.deepEqual(rowOf(blocked.value).allowed, []) + assert.equal(await onEvent(event({ id: 'e3', openId: 'ou_stranger_1' })), 'refused') + assert.equal(delivered.length, 1) +}) + +test('#1266: Disconnect closes the socket, deletes both credentials, and no turn starts after it', async (t) => { + const feishu = fakeFeishu() + const realFetch = globalThis.fetch + globalThis.fetch = feishu.fetch + const { rt, ctx, dials, closes, home } = rig(t) + t.after(() => { globalThis.fetch = realFetch }) + const delivered = [] + rt.remoteChats.open = async () => 'chat' + rt.remoteChats.deliver = async message => { delivered.push(message); return 'chat' } + + await call(ctx, 'connector-connect', { id: 'feishu', mode: 'app', credentials: { app_id: 'cli_app_1', app_secret: 'app-secret-value' } }) + const onEvent = dials[0] + const off = await call(ctx, 'connector-disconnect', { id: 'feishu' }) + assert.equal(off.ok, true, JSON.stringify(off)) + const row = rowOf(off.value) + assert.equal(row.state, 'off') + assert.equal(row.mode, null) + assert.equal(row.inbound.state, 'off') + assert.deepEqual(row.allowed, []) + assert.equal(closes.length, 1, 'the socket was closed, not leaked') + assert.equal(rt.secretValue('FEISHU_APP_SECRET'), undefined) + assert.equal(JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).connectors?.feishu, undefined) + + assert.equal(await onEvent(event({ id: 'e9' })), 'off', 'an event a socket still hands over starts nothing while off') + assert.equal(delivered.length, 0) +}) + +test('#1266: webhook mode is today\'s sender: one test POST, ready, no socket and no guest list', async (t) => { + const feishu = fakeFeishu([answer(200, { code: 0, data: { message_id: 'om_test' } })]) + const realFetch = globalThis.fetch + globalThis.fetch = feishu.fetch + const { ctx, dials } = rig(t) + t.after(() => { globalThis.fetch = realFetch }) + + const connected = await call(ctx, 'connector-connect', { id: 'feishu', mode: 'webhook', credentials: { webhook: 'https://feishu.test/open-apis/bot/v2/hook/abc' } }) + assert.equal(connected.ok, true, JSON.stringify(connected)) + const row = rowOf(connected.value) + assert.equal(row.state, 'ready') + assert.equal(row.mode, 'webhook') + assert.equal(row.inbound.state, 'off') + assert.equal(dials.length, 0, 'webhook mode dials nothing') + assert.equal(feishu.calls.filter(entry => entry.url.includes('/bot/v2/hook/')).length, 1, 'one test message, as phase 1') + const guest = await call(ctx, 'connector-allow', { id: 'feishu', openId: 'ou_someone_1' }) + assert.equal(guest.ok, false, 'a webhook-only row has no guest list') +}) + +test('#1266: a half-saved app pair dials nothing and says so on the row', async (t) => { + const { ctx, dials } = rig(t, { record: { mode: 'app', appId: 'cli_app_1' } }) + const row = await until(async () => rowOf((await call(ctx, 'state', {})).value), value => value?.state === 'error') + assert.equal(row.state, 'error') + assert.match(row.error, /App ID and App Secret/) + assert.equal(dials.length, 0, 'one half of a pair never opens a socket') + assert.equal(row.inbound.state, 'off') +}) + +test('#1266: app mode answers back into the same Feishu chat, as the app', async (t) => { + const feishu = fakeFeishu() + const realFetch = globalThis.fetch + globalThis.fetch = feishu.fetch + t.after(() => { globalThis.fetch = realFetch }) + + const handed = [] + const reply = feishuReply({ + appOf: () => ({ appId: 'cli_app_1', appSecret: 'app-secret-value' }), + secretValue: () => undefined, + sendApp: async (message, options) => { handed.push({ message, options }) }, + }) + await reply({ handle: 'feishu:oc_chat_9', text: 'the answer' }) + assert.deepEqual(handed[0].message, { appId: 'cli_app_1', appSecret: 'app-secret-value', chatId: 'oc_chat_9', text: 'the answer' }) + await assert.rejects(reply({ handle: 'not-a-feishu-chat', text: 'x' }), /Not sent: this answer has no Feishu chat/) + + await feishuAppSend( + { appId: 'cli_app_1', appSecret: 'app-secret-value', chatId: 'oc_chat_9', text: 'the answer' }, + { api: 'https://feishu.test' }, + ) + const send = feishu.calls.find(entry => entry.url.includes('/open-apis/im/v1/messages')) + assert.equal(send.url, 'https://feishu.test/open-apis/im/v1/messages?receive_id_type=chat_id') + assert.equal(send.body.receive_id, 'oc_chat_9') + assert.equal(send.body.msg_type, 'text') + assert.deepEqual(JSON.parse(send.body.content), { text: 'the answer' }) + assert.equal(send.headers.authorization, 'Bearer t_app_token') +}) + +test('#1266: feishu is a name a saved capability may not take', () => { + assert.ok(RESERVED_NAMES.includes('feishu')) +}) diff --git a/core/test/feishu-inbound.test.js b/core/test/feishu-inbound.test.js index 9f9e56cc..ee333f72 100644 --- a/core/test/feishu-inbound.test.js +++ b/core/test/feishu-inbound.test.js @@ -1,6 +1,6 @@ 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' +import { APP_SECRET_SECRET, DEDUPE_RING, MAX_REFUSED, 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 @@ -142,27 +142,31 @@ test('readEvent reads only the envelope phase 2 means to accept (#1266)', () => 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 app pair is one credential: it is the record\'s id and the saved secret, either half missing dials nothing (#1266)', () => { + const read = name => (name === APP_SECRET_SECRET ? 'app-secret-value' : undefined) + assert.deepEqual(appCredentials({ appId: 'cli_app_1' }, read), { appId: 'cli_app_1', appSecret: 'app-secret-value' }) + // The id is not a secret: it lives in the connector record, so a missing record field is the half + // that is missing, without anything having to be saved on the Secrets page. + assert.equal(appCredentials({}, read), undefined) + assert.equal(appCredentials({ appId: ' ' }, read), undefined) + assert.equal(appCredentials({ appId: 'cli_app_1' }, () => ' '), undefined) + assert.equal(appCredentials({ appId: 'cli_app_1' }, () => undefined), undefined) + assert.equal(appCredentials(undefined, read), 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 secrets = { [APP_SECRET_SECRET]: 'app-secret-value' } + const app = { appId: 'cli_app_1' } const seen = [] const transport = { - connect: async () => { throw new Error(`handshake refused for ${secrets[APP_ID_SECRET]} / ${secrets[APP_SECRET_SECRET]}`) }, + connect: async () => { throw new Error(`handshake refused for ${app.appId} / ${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]'), + redact: text => String(text).split(secrets[APP_SECRET_SECRET]).join('[redacted]').split(app.appId).join('[redacted]'), }) await inbound.start() const view = JSON.stringify(inbound.view()) @@ -263,3 +267,42 @@ test('two copies of one event arriving together still start one turn (#1266)', a assert.deepEqual(results.sort(), ['delivered', 'duplicate']) assert.equal(turns.length, 1) }) + +// #1266, the connector-row half: who may talk is the row's list (owner plus allowed senders), and a +// refused sender is kept as one bounded line the row can offer Allow on. +test('a sender the row allowed starts a turn; one it did not is refused (#1266)', async () => { + const turns = [] + let allowed = ['ou_guest'] + const inbound = createInbound({ + enabled: true, + allowed: () => allowed.map(openId => ({ openId })), + transport: {}, + deliver: async message => { turns.push(message) }, + }) + assert.equal(await inbound.handleEvent(event({ id: 'g1', openId: 'ou_guest' })), 'delivered') + allowed = [] + assert.equal(await inbound.handleEvent(event({ id: 'g2', openId: 'ou_guest' })), 'refused') + assert.equal(turns.length, 1) +}) + +test('the refused list is one row per sender, bounded, and an allowed sender leaves it (#1266)', async () => { + let allowed = [] + const inbound = createInbound({ + enabled: true, + allowed: () => allowed, + transport: { nameOf: async openId => `Name ${openId}` }, + deliver: async () => { throw new Error('nobody is allowed yet') }, + now: () => Date.parse('2026-10-26T10:00:00Z'), + }) + for (let n = 0; n < MAX_REFUSED + 5; n += 1) await inbound.handleEvent(event({ id: `r-${n}`, openId: `ou_${n}` })) + const refused = inbound.view().refusedSenders + assert.equal(refused.length, MAX_REFUSED, 'a stranger who writes many times cannot grow the row') + assert.equal(refused.at(-1).openId, `ou_${MAX_REFUSED + 4}`, 'the newest line is the one kept') + assert.equal(refused.at(-1).name, `Name ou_${MAX_REFUSED + 4}`) + assert.equal(refused.at(-1).at, '2026-10-26T10:00:00.000Z') + // One sender writing again is one line with a fresh time, not two lines. + await inbound.handleEvent(event({ id: 'again', openId: `ou_${MAX_REFUSED + 4}` })) + assert.equal(inbound.view().refusedSenders.length, MAX_REFUSED) + allowed = [`ou_${MAX_REFUSED + 4}`] + assert.ok(!inbound.view().refusedSenders.some(entry => entry.openId === `ou_${MAX_REFUSED + 4}`), 'allowing someone clears their line at once') +}) diff --git a/core/test/feishu.test.js b/core/test/feishu.test.js index 149f5322..c0662ce9 100644 --- a/core/test/feishu.test.js +++ b/core/test/feishu.test.js @@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url' import { runInNewContext } from 'node:vm' import { build } from 'esbuild' import { apply } from '../index.js' -import { feishuPost } from '../src/host/connectors.js' +import { FEISHU_MODES, feishuPost } from '../src/host/connectors.js' import { createNotifier } from '../src/host/notify.js' import { fakeContext } from './fake-ctx.js' @@ -63,7 +63,10 @@ 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 INBOUND_OFF = { state: 'off', connected: 0, duplicates: 0, refused: 0, refusedSenders: [], app: 'missing' } +// #1266: the sender row became a channel with two modes. Spelled out here, so a field leaking into +// the row (or a mode arriving from nowhere) cannot pass unnoticed. +const feishuRow = (state, mode = null) => ({ id: 'feishu', name: 'Feishu', state, kind: 'channel', account: null, mode, modes: FEISHU_MODES, allowed: [], inbound: INBOUND_OFF }) 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). @@ -81,7 +84,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, inbound: INBOUND_OFF }) + assert.deepEqual(row(state.value), feishuRow('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']) }) @@ -97,7 +100,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, inbound: INBOUND_OFF }) + assert.deepEqual(row(connected.value), feishuRow('ready', 'webhook')) assert.equal(feishu.posts.length, 1, 'one test POST on connect') assert.equal(feishu.posts[0].url, webhook) @@ -239,21 +242,46 @@ const renderRow = (connector, given = []) => { return tree } -test('#1263: the Feishu row asks for a webhook URL and links to Feishu, not to GitHub', () => { - // form open, so the masked input and the link are drawn - const tree = renderRow({ id: 'feishu', name: 'Feishu', state: 'off', kind: 'sender', account: null }, [true, false, '', [], false, false, null]) +const FEISHU_OFF = { id: 'feishu', name: 'Feishu', state: 'off', kind: 'channel', account: null, mode: null, modes: FEISHU_MODES, allowed: [] } +const FEISHU_APP = { ...FEISHU_OFF, state: 'ready', mode: 'app', allowed: [{ openId: 'ou_owner_1', name: 'Owner' }], inbound: { state: 'ready', refusedSenders: [{ openId: 'ou_stranger_1', name: 'Mallory', at: '2026-10-26T10:00:00.000Z' }] } } + +test('#1266: the Feishu row offers both ways to connect, and the webhook one links to Feishu', () => { + // form open, so the two mode buttons, the field and the link are drawn + const tree = renderRow(FEISHU_OFF, [true, false, '', [], false, false, null]) + const buttons = all(tree).filter(node => node.type === 'button') + const modeButtons = buttons.filter(node => node.props['aria-pressed'] !== undefined && !node.props.className?.includes('bt-app-chip')) + assert.deepEqual(modeButtons.map(text), ['Custom bot webhook', 'App (long connection)'], 'one row, two modes, not a second Feishu row') const input = all(tree).find(node => node.type === 'input') - assert.equal(input.props['aria-label'], 'Feishu webhook URL') + assert.equal(input.props['aria-label'], 'Webhook URL', 'the webhook mode asks for its one credential') const link = all(tree).find(node => node.type === 'a') - assert.doesNotMatch(link.props.href, /github\.com/, 'the row must not send the owner to GitHub for a Feishu webhook') + assert.doesNotMatch(link.props.href, /github\.com/, 'the row must not send the owner to GitHub for Feishu') assert.match(link.props.href, /feishu/i) assert.doesNotMatch(text(tree), /GitHub token/) }) -test('#1263: a connected Feishu offers no per-Bot switch, because it has no tools', () => { - const tree = renderRow({ id: 'feishu', name: 'Feishu', state: 'ready', kind: 'sender', account: null }) - const labels = all(tree).filter(node => node.type === 'button').map(text) - assert.ok(labels.includes('Disconnect'), labels.join('|')) - assert.ok(!labels.includes('Choose Bots'), 'a sender has no tools, so a per-Bot switch would do nothing') - assert.doesNotMatch(text(tree), /On for all \d+ Bots/) +test('#1266: app mode asks for the App ID and the App Secret, and the App ID is not masked', () => { + const tree = renderRow({ ...FEISHU_OFF, mode: 'app' }, [true, false, '', [], false, false, null]) + const inputs = all(tree).filter(node => node.type === 'input') + assert.deepEqual(inputs.map(node => node.props['aria-label']).filter(Boolean).slice(0, 3), ['App ID', 'App Secret']) + assert.equal(inputs[0].props.type, 'text', 'the App ID is kept in the record, so it is not a secret field') + assert.equal(all(tree).find(node => node.type === 'a').props.href.includes('app'), true, 'the link is how to create the app') +}) + +test('#1266: a webhook-only Feishu offers no per-Bot switch; the app mode does, with the guest list', () => { + const webhook = renderRow({ ...FEISHU_OFF, state: 'ready', mode: 'webhook' }) + const webhookLabels = all(webhook).filter(node => node.type === 'button').map(text) + assert.ok(webhookLabels.includes('Disconnect'), webhookLabels.join('|')) + assert.ok(!webhookLabels.includes('Choose Bots'), 'a webhook is the phase-1 sender: a per-Bot switch would do nothing') + assert.doesNotMatch(text(webhook), /On for all \d+ Bots/) + assert.doesNotMatch(text(webhook), /Who may talk to the Bots/) + + const app = renderRow(FEISHU_APP) + const appLabels = all(app).filter(node => node.type === 'button').map(text) + assert.ok(appLabels.includes('Choose Bots'), 'an app gives a Bot a way in, so the switch is real') + assert.match(text(app), /Off for every Bot|On for all \d+ Bots/, 'the row says which Bots may reach it') + assert.match(text(app), /Who may talk to the Bots/) + assert.match(text(app), /Owner/, 'who is allowed is on the row') + assert.match(text(app), /Mallory/, 'and who was turned away is there with Allow beside it') + assert.ok(appLabels.includes('Allow') && appLabels.includes('Block')) + assert.ok(!appLabels.includes('Send'), 'app mode answers by itself: there is no room to post into') }) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 1d8d48eb..a4fbd5a2 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -226,6 +226,8 @@ Settings → Connectors (`src/client/connectors.js`) lists one row per connector The Feishu row (#1263) is a **sender** (`kind: 'sender'` from the host, not an id the client knows): the input is labelled **Feishu webhook URL** and its link goes to Feishu's custom-bot page, never to GitHub's token page. Because a sender has no tools there is no **Choose Bots** button and no *On for N Bots* line, and the Bot-settings **Connectors** card skips it (`connectedConnectors` drops `kind === 'sender'`), so no switch is offered that would change nothing. Once it is ready the row carries a text field with **Send**, which is the only caller of `connector-send`; a refused webhook shows **Could not connect: …** with **Try again** beside it. +The same row is the whole act of connecting Feishu (#1266): it draws one row, not two, and the form has a **mode** — **Custom bot webhook** (one masked URL, outbound only, exactly phase 1) or **App (long connection)** (App ID + App Secret, one field per `credential` the definition declares, the App ID not masked because it is not a secret). Connect submits `{ mode, credentials }` through `connectorConnect`, which passes an object body straight to `connector-connect` and keeps the older single `token` body for every other row; the status line names the mode that is live, and **Disconnect** clears both. With `kind: 'channel'` the row offers the same per-Bot **Choose Bots** switch GitHub and Composio have (`connectedConnectors` includes a channel, so `connector-bots.js` lists it; off by default, never for a Bot shared with someone else) — a webhook-only row still shows no switch. App mode also renders **允许的飞书用户**: the refused senders the host reports, each with **Allow** / **Block** (`connector-allow` / `connector-block`), plus a paste-an-`open_id` field for the case where nobody has messaged yet; nobody is allowed until the owner adds them, so connecting the app never lets a stranger spend the owner's tokens. + ### About FlyBot page (#374) `src/client/about.js` shows the FlyBot mark, `Version · ` (the part after the version only for a git install), the Star button (the repository from `update.repoUrl`, the live count only when it came back) and the star request line, then **Updates** (the **FlyBot core** row and the **DeepSeek Harness (underlying)** row, zh `DeepSeek Harness(底层)`) and **Links** (Source code → the repository, Release notes → its releases, Report a problem → Settings → Feedback). For a git install on a branch the **FlyBot core** row reads `Installed from GitHub (main 082563a). Updates come from the maintainer: reinstall FlyBot to get them.`, and after **Check for updates** `Up to date with main.`, `1 commit behind main. …`, `N commits behind main. …` or `Could not compare with main. …` plus `· Checked