diff --git a/changelog.d/1263-feishu.added.md b/changelog.d/1263-feishu.added.md new file mode 100644 index 000000000..b87d27659 --- /dev/null +++ b/changelog.d/1263-feishu.added.md @@ -0,0 +1 @@ +Feishu (Lark) connector, phase 1: paste a group's custom-bot webhook URL on Settings → Connectors and this instance can post a text message into that group. The row is a sender — no Bot tools, no per-Bot switch — it proves the webhook with one test message when you connect, shows the reason on the row when Feishu refuses, and sends on demand with the existing retry policy (a rate limit is retried, a refused signature is not). The URL is kept as a secret and never appears in `state.json`, the view or a log line. ([#1263](https://github.com/ThinkFlowLab/FlyBot/issues/1263)) diff --git a/core/client.js b/core/client.js index 7a2adeb70..548a30796 100644 --- a/core/client.js +++ b/core/client.js @@ -318,6 +318,12 @@ var ZH = { "This DSH cannot load connectors": "这个 DSH 不能加载连接器", "No such connector": "没有这个连接器", "Gmail, Slack, Calendar, Notion and many more apps, through one Composio key": "Gmail、Slack、日历、Notion 等众多应用,一个 Composio key 就能连", + "Posts into one Feishu group through a custom bot webhook": "通过自定义机器人 webhook 发到你的一个飞书群", + "Feishu webhook URL": "飞书 webhook 地址", + "Get a webhook URL in Feishu": "去飞书获取 webhook 地址", + "Message to send to Feishu": "要发到飞书的消息", + "Type a message for the Feishu group": "输入要发到飞书群的消息", + Send: "发送", "Connected: {apps}": "已连接:{apps}", "Composio API key": "Composio API key", "Get a Composio API key": "去 Composio 获取 API key", @@ -948,7 +954,6 @@ var ZH = { Remove: "删除", "Ask or update": "询问或更新", "Ask about or update {name}'s memory": "询问或更新 {name} 的记忆", - Send: "发送", "Added: {text}": "已添加:{text}", "Added to team memory: {text}": "已加入团队记忆:{text}", "Updated: {text}": "已更新:{text}", @@ -12313,7 +12318,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"); +var connectedConnectors = (roster) => (roster?.connectors ?? []).filter((connector) => connector.state !== "off" && connector.kind !== "sender"); 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."); @@ -15682,6 +15687,7 @@ button.bt-us-row:hover{background:var(--bt-hover)} var import_react36 = require("react"); var TOKEN_URL = "https://github.com/settings/personal-access-tokens/new"; var COMPOSIO_KEY_URL = "https://platform.composio.dev/"; +var FEISHU_WEBHOOK_URL = "https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot"; var APPS = [ ["gmail", "Gmail"], ["googlecalendar", "Google Calendar"], @@ -15717,10 +15723,18 @@ var GitHubMark = () => (0, import_react36.createElement)( { width: 20, height: 20, viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": true }, (0, import_react36.createElement)("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" }) ); -var MARKS = { github: GitHubMark, composio: ConnectorIcon }; +var FeishuMark = () => (0, import_react36.createElement)( + "svg", + { width: 20, height: 20, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, + (0, import_react36.createElement)("path", { d: "M3.2 6.6c3 .1 6.4 1.5 8.6 3.9-1.9.4-3.5 1.4-4.7 2.9-1.9-.6-3.4-1.9-3.9-3.6-.2-.7-.2-1.5 0-3.2z" }), + (0, import_react36.createElement)("path", { d: "M16.8 5.4c-2.9.2-5.6 1.5-7.4 3.4 1.7 1.2 3.1 2.8 3.9 4.9 2.2-1.1 3.8-3.2 4-5.6.1-.9-.1-1.9-.4-2.7z" }), + (0, import_react36.createElement)("path", { d: "M8.6 12.1c.9.7 1.7 1.6 2.2 2.7-1.1.2-2.3 0-3.3-.6.2-.8.6-1.5 1.1-2.1z" }) +); +var MARKS = { github: GitHubMark, composio: ConnectorIcon, feishu: FeishuMark }; var ABOUT = { github: () => t("Repositories, issues, and pull requests"), - composio: () => t("Gmail, Slack, Calendar, Notion and many more apps, through one Composio key") + composio: () => t("Gmail, Slack, Calendar, Notion and many more apps, through one Composio key"), + feishu: () => t("Posts into one Feishu group through a custom bot webhook") }; function statusText2(connector) { if (connector.state === "connecting") return t("Connecting…"); @@ -15801,6 +15815,7 @@ function ConnectorRow({ connector, roster, actions }) { const [picking, setPicking] = (0, import_react36.useState)(false); const [busy, setBusy] = (0, import_react36.useState)(false); const [error, setError] = (0, import_react36.useState)(null); + const [message, setMessage] = (0, import_react36.useState)(""); const run = (task, after) => { setBusy(true); setError(null); @@ -15809,6 +15824,7 @@ function ConnectorRow({ connector, roster, actions }) { }, (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); }; const gateway = connector.id === "composio"; + const sender = connector.kind === "sender"; const [statuses, setStatuses] = (0, import_react36.useState)({}); const appsKey = (connector.apps ?? []).join(","); (0, import_react36.useEffect)(() => { @@ -15871,22 +15887,33 @@ function ConnectorRow({ connector, roster, actions }) { setApps(connector.apps ?? []); setPicking(true); } }, t("Choose apps")) : null, - on ? (0, import_react36.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 && !sender ? (0, import_react36.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_react36.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_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setForm(true) }, t("Connect")) ) ), on && gateway && !picking ? (0, import_react36.createElement)(AppStatusList, { apps: connector.apps ?? [], statuses }) : null, - on ? (0, import_react36.createElement)("span", { className: "bt-connector-uses" }, usageText(roster, connector.id)) : null, + on && !sender ? (0, import_react36.createElement)("span", { className: "bt-connector-uses" }, usageText(roster, connector.id)) : null, on && bots ? (0, import_react36.createElement)(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null, + on && sender ? (0, import_react36.createElement)( + "div", + { className: "bt-connector-form" }, + (0, import_react36.createElement)("label", { className: "bt-q-field" }, (0, import_react36.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) })), + (0, import_react36.createElement)( + "div", + { className: "bt-connector-form-row" }, + (0, import_react36.createElement)("span", { className: "bt-connector-link" }), + (0, import_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || message.trim() === "" || actions.connectorSend === void 0, onClick: () => void run(actions.connectorSend(connector.id, message), () => setMessage("")) }, t("Send")) + ) + ) : null, !on && form ? (0, import_react36.createElement)( "div", { className: "bt-connector-form" }, - (0, import_react36.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react36.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })), + (0, import_react36.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react36.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: sender ? t("Feishu webhook URL") : gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })), gateway ? (0, import_react36.createElement)(AppPicker, { apps, onChange: setApps, search: token.trim() === "" ? void 0 : search }) : null, (0, import_react36.createElement)( "div", { className: "bt-connector-form-row" }, - (0, import_react36.createElement)("a", { className: "bt-connector-link", href: gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, gateway ? t("Get a Composio API key") : t("Create a token on GitHub")), + (0, import_react36.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_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { setForm(false); setToken(""); @@ -19821,6 +19848,7 @@ function apply(ctx) { connectorAppStatus: (id) => call("connector-app-status", { id }), connectorDisconnect: (id) => after(call("connector-disconnect", { id })), connectorRetry: (id) => after(call("connector-retry", { id })), + connectorSend: (id, text) => after(call("connector-send", { id, text })), saveCapability: (capability) => after(call("capability-save", capability)), deleteCapability: (name) => after(call("capability-delete", { name })), approveCapability: (name) => after(call("capability-approve", { name })), diff --git a/core/src/client/connector-bots.js b/core/src/client/connector-bots.js index 21a6d5143..27fd56edd 100644 --- a/core/src/client/connector-bots.js +++ b/core/src/client/connector-bots.js @@ -12,7 +12,9 @@ export function ownBots(roster) { return (roster?.bots ?? []).filter(bot => !bot.remote && (me === undefined || me === null || bot.ownerId === undefined || bot.ownerId === me)) } -export const connectedConnectors = roster => (roster?.connectors ?? []).filter(connector => connector.state !== 'off') +// 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') // 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 3221f0548..7d2564123 100644 --- a/core/src/client/connectors.js +++ b/core/src/client/connectors.js @@ -8,6 +8,8 @@ import { t } from './i18n.js' const TOKEN_URL = 'https://github.com/settings/personal-access-tokens/new' const COMPOSIO_KEY_URL = 'https://platform.composio.dev/' +// Feishu's own page for a group's custom bot, where the webhook URL comes from. +const FEISHU_WEBHOOK_URL = 'https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot' // Common apps, searched when Composio cannot be asked; any other app is found by the search. const APPS = [ @@ -23,10 +25,17 @@ const appName = slug => APPS.find(([id]) => id === slug)?.[1] ?? slug export const GitHubMark = () => h('svg', { width: 20, height: 20, viewBox: '0 0 16 16', fill: 'currentColor', 'aria-hidden': true }, h('path', { d: 'M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z' })) -const MARKS = { github: GitHubMark, composio: ConnectorIcon } +// Feishu's mark: two stacked wings around a middle line, drawn here rather than copied. +export const FeishuMark = () => h('svg', { width: 20, height: 20, viewBox: '0 0 20 20', fill: 'currentColor', 'aria-hidden': true }, + h('path', { d: 'M3.2 6.6c3 .1 6.4 1.5 8.6 3.9-1.9.4-3.5 1.4-4.7 2.9-1.9-.6-3.4-1.9-3.9-3.6-.2-.7-.2-1.5 0-3.2z' }), + h('path', { d: 'M16.8 5.4c-2.9.2-5.6 1.5-7.4 3.4 1.7 1.2 3.1 2.8 3.9 4.9 2.2-1.1 3.8-3.2 4-5.6.1-.9-.1-1.9-.4-2.7z' }), + h('path', { d: 'M8.6 12.1c.9.7 1.7 1.6 2.2 2.7-1.1.2-2.3 0-3.3-.6.2-.8.6-1.5 1.1-2.1z' })) + +const MARKS = { github: GitHubMark, composio: ConnectorIcon, feishu: FeishuMark } const ABOUT = { github: () => t('Repositories, issues, and pull requests'), composio: () => t('Gmail, Slack, Calendar, Notion and many more apps, through one Composio key'), + feishu: () => t('Posts into one Feishu group through a custom bot webhook'), } function statusText(connector) { @@ -104,12 +113,15 @@ function ConnectorRow({ connector, roster, actions }) { const [picking, setPicking] = useState(false) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) + const [message, setMessage] = useState('') const run = (task, after) => { setBusy(true) setError(null) return task.then(() => { after?.() }, failure => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)) } 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' 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. @@ -143,18 +155,23 @@ 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 ? 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 && !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 ? 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 ? h('span', { className: 'bt-connector-uses' }, usageText(roster, connector.id)) : null, + on && !sender ? 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' }, + 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: gateway ? t('Composio API key') : t('GitHub token'), autoFocus: true })), + 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 })), 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: gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: '_blank', rel: 'noopener noreferrer' }, gateway ? t('Get a Composio API key') : t('Create a token on GitHub')), + 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, on && gateway && picking ? h('div', { className: 'bt-connector-form' }, diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 282f95bd2..06ac1cb50 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -282,6 +282,12 @@ const ZH = { 'This DSH cannot load connectors': '这个 DSH 不能加载连接器', 'No such connector': '没有这个连接器', 'Gmail, Slack, Calendar, Notion and many more apps, through one Composio key': 'Gmail、Slack、日历、Notion 等众多应用,一个 Composio key 就能连', + 'Posts into one Feishu group through a custom bot webhook': '通过自定义机器人 webhook 发到你的一个飞书群', + 'Feishu webhook URL': '飞书 webhook 地址', + 'Get a webhook URL in Feishu': '去飞书获取 webhook 地址', + 'Message to send to Feishu': '要发到飞书的消息', + 'Type a message for the Feishu group': '输入要发到飞书群的消息', + Send: '发送', 'Connected: {apps}': '已连接:{apps}', 'Composio API key': 'Composio API key', 'Get a Composio API key': '去 Composio 获取 API key', @@ -895,7 +901,6 @@ const ZH = { Remove: '删除', 'Ask or update': '询问或更新', "Ask about or update {name}'s memory": '询问或更新 {name} 的记忆', - Send: '发送', 'Added: {text}': '已添加:{text}', 'Added to team memory: {text}': '已加入团队记忆:{text}', 'Updated: {text}': '已更新:{text}', diff --git a/core/src/client/index.js b/core/src/client/index.js index 3594a08cb..069d0b047 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -602,6 +602,7 @@ export function apply(ctx) { connectorAppStatus: id => call('connector-app-status', { id }), connectorDisconnect: id => after(call('connector-disconnect', { id })), connectorRetry: id => after(call('connector-retry', { id })), + connectorSend: (id, text) => after(call('connector-send', { id, text })), saveCapability: capability => after(call('capability-save', capability)), deleteCapability: name => after(call('capability-delete', { name })), approveCapability: name => after(call('capability-approve', { name })), diff --git a/core/src/host/authz.js b/core/src/host/authz.js index e46b92211..e317a6de0 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', 'bot-connectors', + 'connector-connect', 'connector-apps', 'connector-app-search', 'connector-app-status', '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 52b498f55..9f4fabe0e 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -491,6 +491,7 @@ export function install(rt) { case 'connector-app-status': case 'connector-disconnect': case 'connector-retry': + case 'connector-send': await rt.load() return ok(await rt.connectorEndpoint(endpoint, body)) case 'capability-save': diff --git a/core/src/host/connectors.js b/core/src/host/connectors.js index 6b0e84c06..bdfc219c0 100644 --- a/core/src/host/connectors.js +++ b/core/src/host/connectors.js @@ -17,11 +17,14 @@ // without touching the mount. import { randomUUID } from 'node:crypto' +import { createNotifier } from './notify.js' export const GITHUB = 'github' export const GITHUB_SECRET = 'GITHUB_TOKEN' export const COMPOSIO = 'composio' 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] const MCP_CLIENT = '@deepseek-ai/dsh-mcp-client' @@ -31,7 +34,15 @@ const GITHUB_DEFAULTS = { toolsets: 'context,repos,issues,pull_requests', } const COMPOSIO_DEFAULTS = { api: 'https://backend.composio.dev/api/v3.1' } +const FEISHU_DEFAULTS = { api: 'https://open.feishu.cn' } const CHECK_TIMEOUT_MS = 15_000 +// A send waits longer than a connect: the connect is one small probe, while a message may sit +// behind Feishu's own queue. The retry budget is notify.js's, not this deadline. +const SEND_TIMEOUT_MS = 60_000 +// Feishu's custom-bot webhook answers HTTP 200 with the verdict in the body, so a rate limit has +// to be read out of `code`; 9499 is the one code that means "the same call may work again". +export const FEISHU_RATE_LIMITED = 9499 +const FEISHU_TEST_TEXT = 'FlyBot is connected.' const INSECURE = name => `${name} addresses must start with https://` const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/ const APP_SLUG = /^[a-z0-9][a-z0-9_-]{0,63}$/ @@ -237,6 +248,74 @@ async function composioEnsureSession(apiKey, { api, userId, apps, stored, fetch: return { id, url, apps } } +// The webhook URL is the credential, so it is checked against the API address before anything is +// sent: https, on the host this instance was configured for. Never another host, never http. +function webhookAddress(webhook, api) { + let url + try { + url = new URL(String(webhook).trim()) + } catch { + throw new Error('Paste the webhook URL Feishu gave you') + } + const base = new URL(String(api)) + if (url.protocol !== 'https:' || url.origin !== base.origin) throw new Error(`A Feishu webhook must be https on ${base.origin}`) + return url.href +} + +// A fetch that ignores its abort signal would otherwise hang a connect (or a send) for ever. +function withDeadline(promise, ms) { + let timer + const deadline = new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('deadline')), Math.max(1, ms)) }) + return Promise.race([promise, deadline]).finally(() => clearTimeout(timer)) +} + +// One text message through a custom-bot webhook, answered in notify.js's vocabulary: `{ ok, ts }` +// or `{ ok: false, error }`, where `rate_limited` and `service_unavailable` may pass on their own +// and every other error is a shape Feishu will refuse again. The URL is never part of the answer. +export async function feishuPost(webhook, text, { fetch: request = fetch, timeoutMs = CHECK_TIMEOUT_MS } = {}) { + let response + try { + response = await withDeadline(request(webhook, { + method: 'POST', + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ msg_type: 'text', content: { text: String(text) } }), + redirect: 'error', + signal: AbortSignal.timeout(Math.max(1, timeoutMs)), + }), timeoutMs) + } catch { + return { ok: false, error: 'network' } + } + const body = await response.json().catch(() => ({})) + if (response.status === 429) { + const asked = Number(response.headers?.get?.('retry-after')) * 1000 + return { ok: false, error: 'rate_limited', retryAfterMs: Number.isFinite(asked) && asked > 0 ? asked : 0 } + } + if (!response.ok) return { ok: false, error: response.status >= 500 ? 'service_unavailable' : `feishu/http-${response.status}` } + const code = Number(body?.code) + if (code === 0) { + const id = body?.data?.message_id + return { ok: true, ts: typeof id === 'string' && id !== '' ? id : 'sent' } + } + if (code === FEISHU_RATE_LIMITED) return { ok: false, error: 'rate_limited', retryAfterMs: 0 } + return { ok: false, error: `feishu/${Number.isFinite(code) ? code : 'unknown'}` } +} + +// The connect step: one test message, so a webhook that cannot post is caught before it is relied +// on. It throws a sentence for the row; nothing it says carries the URL. +export async function feishuCheck(webhook, { api = FEISHU_DEFAULTS.api, fetch: request = fetch, timeoutMs = CHECK_TIMEOUT_MS } = {}) { + const answer = await feishuPost(webhookAddress(webhook, api), FEISHU_TEST_TEXT, { fetch: request, timeoutMs }) + if (answer.ok) return {} + if (answer.error === 'network' || answer.error === 'service_unavailable') throw new Error('Could not reach Feishu') + if (answer.error === 'rate_limited') throw new Error('Feishu is rate-limiting this webhook; try again in a minute') + throw new Error(`Feishu did not accept the webhook (${answer.error})`) +} + +// notify.js's transport for a Feishu webhook: the policy (attempts, backoff, idempotency) stays in +// notify.js, and this only maps one message to one POST. +export function feishuTransport(webhook, { fetch: request = fetch, timeoutMs = SEND_TIMEOUT_MS } = {}) { + return { postMessage: message => feishuPost(webhook, message?.text ?? '', { fetch: request, timeoutMs }) } +} + const DEFINITIONS = { [GITHUB]: { id: GITHUB, @@ -293,6 +372,26 @@ 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]: { + id: FEISHU, + name: 'Feishu', + secret: FEISHU_SECRET, + purpose: 'Feishu webhook', + defaults: FEISHU_DEFAULTS, + addresses: options => [options.api], + kind: 'sender', + paste: 'Paste a webhook URL first', + async check({ token, options }) { + return feishuCheck(token, { api: options.api }) + }, + post: (webhook, text) => feishuPost(webhook, text, { timeoutMs: SEND_TIMEOUT_MS }), + mountKey: () => '', + account: () => null, + }, } export function install(rt) { @@ -322,6 +421,33 @@ export function install(rt) { if (fiber !== undefined) await Promise.resolve().then(() => fiber.dispose()).catch(() => { /* a fiber that fails to dispose must not block unloading */ }) } + // 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({ + channel: c.def.id, + log: (format, ...args) => rt.warn(`ds-bot: ${format}`, ...args), + transport: { + postMessage: (message) => { + const webhook = rt.secretValue(c.def.secret) + if (webhook === undefined) return { ok: false, error: 'channel_not_found' } + return c.def.post(webhook, message?.text ?? '') + }, + }, + })])) + const notifierOf = c => notifiers.get(c.def.id) + + // The connect step of a sender: one test message, then ready or the reason. A sender is "ready" + // once this passes, and the failure is a row the owner can read and Try again from. + const probe = async (c, token) => { + try { + await c.def.check({ token, options: c.options }) + c.status = { state: 'ready' } + } catch (error) { + c.status = { state: 'error', error: reason(error) } + rt.warn('ds-bot: %s connector failed: %s', c.def.name, c.status.error) + } + } + // The MCP client wraps the server's answer (a 401, say) as the cause. The session address of a // mount in progress is not kept anywhere, so it is also taken out of what is shown or logged. let addresses = [] @@ -339,6 +465,7 @@ export function install(rt) { if (!force && c.mount !== null && c.mount.key === key) return await unmount(c) if (token === undefined) { + notifierOf(c)?.setEnabled(false) c.status = { state: 'off' } return } @@ -348,6 +475,16 @@ 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 } @@ -397,6 +534,7 @@ export function install(rt) { name: c.def.name, state: c.status.state, ...(c.status.error !== undefined ? { error: c.status.error } : {}), + ...(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) : {}), }) @@ -423,9 +561,25 @@ export function install(rt) { async 'connector-connect'(body) { const c = known(body.id) const token = typeof body.token === 'string' ? body.token.trim() : '' - if (token === '') throw new Error(c.def.id === GITHUB ? 'Paste a token first' : 'Paste a key first') + 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 }) @@ -518,6 +672,25 @@ export function install(rt) { await sync(c, true) 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). + async 'connector-send'(body) { + const c = known(body.id) + if (c.def.kind !== 'sender') 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)) + await rt.loadSecrets() + if (rt.secretValue(c.def.secret) === undefined) throw new Error(`Connect ${c.def.name} first`) + const notifier = notifierOf(c) + // A fresh key per call: two messages with the same text are two messages, not a duplicate. + const result = await notifier.send({ key: randomUUID(), text }) + const sent = result.state === 'sent' || result.state === 'duplicate' + const failure = `Feishu refused the message (${result.error ?? result.reason ?? 'no answer'})` + await keep(c, { ...saved(c), ...(sent ? { lastOkAt: Date.now() } : { lastError: failure }) }) + if (!sent) throw new Error(failure) + return connectorsView() + }, } const connectorEndpoint = (endpoint, body) => endpoints[endpoint](body ?? {}) diff --git a/core/test/connectors.test.js b/core/test/connectors.test.js index be57b6fba..b256ca55e 100644 --- a/core/test/connectors.test.js +++ b/core/test/connectors.test.js @@ -189,19 +189,25 @@ test('deleting GITHUB_TOKEN on the Secrets page unmounts GitHub', async (t) => { assert.equal(state.value.connectors[0].state, 'off') }) -test('github: false and composio: false hide the connectors', async (t) => { +test('github: false, composio: false and feishu: false hide the connectors', async (t) => { const home = teamHome() const ctx = fakeContext() capture(ctx) apply(ctx, { home, github: false }) t.after(async () => { await ctx.dispose(); await cleanHome(home) }) - assert.deepEqual((await call(ctx, 'state', {})).value.connectors.map(entry => entry.id), ['composio']) + assert.deepEqual((await call(ctx, 'state', {})).value.connectors.map(entry => entry.id), ['composio', 'feishu']) const other = teamHome() const second = fakeContext() capture(second) apply(second, { home: other, github: false, composio: false }) t.after(async () => { await second.dispose(); await cleanHome(other) }) - assert.deepEqual((await call(second, 'state', {})).value.connectors, []) + assert.deepEqual((await call(second, 'state', {})).value.connectors.map(entry => entry.id), ['feishu']) + const third = teamHome() + const last = fakeContext() + capture(last) + apply(last, { home: third, github: false, composio: false, feishu: false }) + t.after(async () => { await last.dispose(); await cleanHome(third) }) + assert.deepEqual((await call(last, 'state', {})).value.connectors, []) }) test('a GitHub address without https refuses to send the token', async (t) => { @@ -409,7 +415,7 @@ test('deleting COMPOSIO_API_KEY on the Secrets page unmounts Composio, and GitHu const view = (await call(ctx, 'state', {})).value.connectors assert.equal(mounts[1].disposed, true) assert.equal(mounts[0].disposed, false) - assert.deepEqual(view.map(entry => [entry.id, entry.state]), [['github', 'ready'], ['composio', 'off']]) + assert.deepEqual(view.map(entry => [entry.id, entry.state]), [['github', 'ready'], ['composio', 'off'], ['feishu', 'off']]) }) test('a Composio address without https refuses to send the key', async (t) => { diff --git a/core/test/feishu.test.js b/core/test/feishu.test.js new file mode 100644 index 000000000..f0755e2a3 --- /dev/null +++ b/core/test/feishu.test.js @@ -0,0 +1,256 @@ +import assert from 'node:assert/strict' +import { randomBytes } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +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 { createNotifier } from '../src/host/notify.js' +import { fakeContext } from './fake-ctx.js' + +// #1263 phase 1: Feishu (Lark) outbound through a custom-bot webhook. Feishu is not an MCP +// server, so it is a *sender* connector: a definition with no mount, one status and one POST. +// Its verdict is the `code` in the body, not the HTTP status — a rate limit and a refused +// signature both arrive as HTTP 200 — which is why the retry policy is notify.js's rather than +// a second one written next to the mount code. + +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 = () => { + const home = mkdtempSync(join(tmpdir(), 'dsb-feishu-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(TEAM)) + 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 }) +const hookToken = () => randomBytes(8).toString('hex') +const webhookOf = token => `https://feishu.test/open-apis/bot/v2/hook/${token}` + +// The connector is installed with its own API address so a test never talks to Feishu. Every +// warn call is kept whole, arguments and all: `reason()` only redacts a secret that is already +// saved, so the argument a message is formatted from is exactly where a pasted webhook leaks. +function setup({ home = teamHome(), fetch: reply, feishu = { api: 'https://feishu.test' } } = {}) { + const warns = [] + const ctx = fakeContext() + const register = ctx.connection.fetch.register + ctx.connection.fetch.register = (route) => { ctx.route = route; return register(route) } + const original = ctx.logger.warn + ctx.logger.warn = (...args) => { warns.push(args); if (original) original(...args) } + apply(ctx, { home, feishu }) + return { ctx, home, warns, reply } +} + +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 row = (view, id = 'feishu') => (view?.connectors ?? view ?? []).find(entry => entry.id === id) +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). +function fakeFeishu(script = [answer(200, { code: 0, msg: 'success', data: { message_id: 'om_1' } })]) { + const posts = [] + const queue = [...script] + const fetch = async (url, init) => { + posts.push({ url, init, body: init?.body === undefined ? undefined : JSON.parse(init.body) }) + return queue.length > 1 ? queue.shift() : queue[0] + } + return { posts, fetch } +} + +test('#1263: Feishu is a sender row of its own, off until a webhook is pasted', async (t) => { + 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 }) + // 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']) +}) + +test('#1263: connecting posts one test message, turns ready, and keeps the webhook out of state, disk and the log', async (t) => { + const token = hookToken() + const webhook = webhookOf(token) + const feishu = fakeFeishu() + const realFetch = globalThis.fetch + globalThis.fetch = feishu.fetch + const { ctx, home, warns } = setup({ fetch: feishu.fetch }) + t.after(async () => { globalThis.fetch = realFetch; await ctx.dispose(); cleanHome(home) }) + + const connected = await call(ctx, 'connector-connect', { id: 'feishu', token: ` ${webhook} ` }) + assert.equal(connected.ok, true, JSON.stringify(connected)) + assert.deepEqual(row(connected.value), { id: 'feishu', name: 'Feishu', state: 'ready', kind: 'sender', account: null }) + + assert.equal(feishu.posts.length, 1, 'one test POST on connect') + assert.equal(feishu.posts[0].url, webhook) + assert.equal(feishu.posts[0].init.method, 'POST') + assert.equal(feishu.posts[0].body.msg_type, 'text') + assert.equal(typeof feishu.posts[0].body.content.text, 'string') + assert.ok(feishu.posts[0].body.content.text.length > 0) + + const state = await call(ctx, 'state', {}) + assert.ok(!JSON.stringify(state).includes(token), 'the view and the state answer never carry the URL') + assert.ok(!readFileSync(join(home, 'state.json'), 'utf8').includes(token), 'state.json never carries the URL') + assert.deepEqual(said(warns, token), [], 'no warn line carries the URL, in any argument') +}) + +test('#1263: a webhook Feishu refuses stays on the row as an error that names no token', async (t) => { + const token = hookToken() + const feishu = fakeFeishu([answer(400, { code: 19021, msg: 'signature error' })]) + const realFetch = globalThis.fetch + globalThis.fetch = feishu.fetch + const { ctx, home, warns } = setup({ fetch: feishu.fetch }) + t.after(async () => { globalThis.fetch = realFetch; await ctx.dispose(); cleanHome(home) }) + + const refused = await call(ctx, 'connector-connect', { id: 'feishu', token: webhookOf(token) }) + assert.equal(refused.ok, true, JSON.stringify(refused)) + const refusedRow = row(refused.value) + assert.equal(refusedRow.state, 'error', 'the test POST is what decides ready') + assert.match(refusedRow.error, /Feishu/) + assert.ok(!JSON.stringify(refused).includes(token), 'the refusal never carries the URL') + assert.equal(feishu.posts.length, 1) + + const state = await call(ctx, 'state', {}) + assert.ok(!JSON.stringify(state).includes(token)) + assert.ok(!readFileSync(join(home, 'state.json'), 'utf8').includes(token), 'state.json never carries the URL') + assert.ok(said(warns, 'Feishu').length > 0, 'the refusal is logged') + assert.deepEqual(said(warns, token), [], 'no warn line carries the URL, in any argument') + assert.deepEqual(state.value.secrets.map(secret => secret.name), ['FEISHU_WEBHOOK']) +}) + +test('#1263: a Feishu that never answers fails on the deadline instead of hanging', async () => { + const started = Date.now() + const answerOrNothing = await feishuPost(webhookOf('x'), 'hello', { fetch: () => new Promise(() => {}), timeoutMs: 25 }) + assert.deepEqual(answerOrNothing, { ok: false, error: 'network' }) + assert.ok(Date.now() - started < 2_000, 'the deadline, not the fetch, ends the wait') +}) + +test('#1263: a rate limit in the body is retried, a refused signature is not', async () => { + // Feishu answers HTTP 200 for both, so the classifier — not the status — decides. + const limited = fakeFeishu([answer(200, { code: 9499, msg: 'too many request' }), answer(200, { code: 0, msg: 'success', data: { message_id: 'om_2' } })]) + const retried = createNotifier({ channel: 'feishu', transport: { postMessage: message => feishuPost(webhookOf('x'), message.text, { fetch: limited.fetch }) }, baseDelayMs: 1, delay: async () => {}, log: () => {} }) + const sent = await retried.send({ key: 'k1', text: 'hello' }) + assert.equal(sent.state, 'sent') + assert.equal(sent.attempts, 2, '9499 is transient: the same call is tried again') + assert.equal(limited.posts.length, 2) + + const refused = fakeFeishu([answer(200, { code: 19021, msg: 'signature error' })]) + const hopeless = createNotifier({ channel: 'feishu', transport: { postMessage: message => feishuPost(webhookOf('x'), message.text, { fetch: refused.fetch }) }, baseDelayMs: 1, delay: async () => {}, log: () => {} }) + const failed = await hopeless.send({ key: 'k2', text: 'hello' }) + assert.equal(failed.state, 'failed') + assert.equal(failed.permanent, true, 'a refused signature will be refused again') + assert.equal(failed.attempts, 1) + assert.equal(refused.posts.length, 1) + assert.match(failed.error, /19021/) +}) + +test('#1263: connector-send posts the text once and records when it last worked', async (t) => { + const token = hookToken() + const feishu = fakeFeishu() + const realFetch = globalThis.fetch + globalThis.fetch = feishu.fetch + const { ctx, home } = setup({ fetch: feishu.fetch }) + t.after(async () => { globalThis.fetch = realFetch; await ctx.dispose(); cleanHome(home) }) + + const early = await call(ctx, 'connector-send', { id: 'feishu', text: 'hello room' }) + assert.equal(early.ok, false) + assert.match(early.error.message, /Connect Feishu/) + + await call(ctx, 'connector-connect', { id: 'feishu', token: webhookOf(token) }) + const sent = await call(ctx, 'connector-send', { id: 'feishu', text: ' hello room ' }) + assert.equal(sent.ok, true, JSON.stringify(sent)) + assert.equal(feishu.posts.length, 2, 'the connect probe and this one message') + assert.equal(feishu.posts[1].body.content.text, 'hello room') + assert.equal(feishu.posts[1].url, webhookOf(token)) + + const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).connectors.feishu + assert.equal(typeof saved.lastOkAt, 'number') + assert.equal(typeof saved.connectedAt, 'number') + assert.ok(!readFileSync(join(home, 'state.json'), 'utf8').includes(token)) + + const empty = await call(ctx, 'connector-send', { id: 'feishu', text: ' ' }) + assert.equal(empty.ok, false, 'an empty message is refused before anything is posted') + assert.equal(feishu.posts.length, 2) +}) + +// --- Settings → Connectors, the Feishu row --- + +const { outputFiles } = await build({ + stdin: { + contents: "export { ConnectorsPage } from './src/client/connectors.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) +const h = (type, props, ...children) => ({ type, props: { ...props, children } }) +let states = [] +const react = { + createElement: h, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {}, + useState: v => [states.length > 0 ? states.shift() : (typeof v === 'function' ? v() : v), () => {}], + useMemo: f => f(), useRef: current => ({ current }), useCallback: f => f, useSyncExternalStore: (_s, get) => get(), +} +const browser = { exports: {} } +runInNewContext(outputFiles[0].text, { + module: browser, exports: browser.exports, document: { hidden: false }, navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, + window: { matchMedia: () => ({ matches: false }) }, setTimeout, clearTimeout, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { ConnectorsPage } = browser.exports + +const expand = (node) => { + if (node === null || node === undefined || typeof node === 'boolean') return null + if (Array.isArray(node)) return node.map(expand) + if (typeof node !== 'object') return node + if (typeof node.type === 'function') return expand(node.type(node.props)) + return { ...node, props: { ...node.props, children: expand(node.props.children) } } +} +const all = (node, out = []) => { if (node && typeof node === 'object') { if (Array.isArray(node)) node.forEach(child => all(child, out)); else { out.push(node); all(node.props?.children, out) } } return out } +const text = node => (node === null || node === undefined ? '' : Array.isArray(node) ? node.map(text).join('') : typeof node === 'object' ? text(node.props.children) : String(node)) + +const actions = { connectorConnect: async () => {}, connectorDisconnect: async () => {}, connectorRetry: async () => {}, connectorSend: async () => {} } +const renderRow = (connector, given = []) => { + states = [...given] + const tree = expand(h(ConnectorsPage, { roster: { connectors: [connector], bots: [], self: { memberId: 'me' } }, actions })) + states = [] + 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 input = all(tree).find(node => node.type === 'input') + assert.equal(input.props['aria-label'], 'Feishu webhook URL') + 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.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/) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 5ad7c1254..e8a76919f 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -222,6 +222,8 @@ A group or face room shows only its own work (#553, owner decision 2026-10-20 23 Settings → Connectors (`src/client/connectors.js`) lists one row per connector from `roster.connectors`. The GitHub row takes a token. The Composio row takes an API key and an app choice: the picked apps as pills (click to remove) and a **Search Composio apps** box that asks `connector-app-search` (debounced; with the key being pasted before connecting, never saved by the search, and with the saved key after) and offers the results to add; without a key to search with, or when the search fails, it filters the built-in common apps instead (#359). It then shows `Connected: `, one line per picked app with **Ready** or **Needs sign-in** from `connector-app-status` (asked when the row is ready and again when the list or the picker changes; nothing is drawn until Composio answers), with **Choose apps** (the same picker, each pill also showing its status) (changes the list through `connector-apps`, no key) and **Disconnect**. The key goes from the masked input to `connector-connect` and is never read back. Copy is English with a zh entry for every string in `i18n.js`. Under a connected connector the row reads `On for all N Bots`, `On for K of N Bots` or `Off for every Bot. Choose which Bots may use it.`, and **Choose Bots** opens one switch per local Bot (`src/client/connector-bots.js`, `setBotConnector(botId, connector, enabled)` → `bot-connectors`, #335, #369, #601): the Bot's name, **on by default**; only an explicit `bot.connectors[id] === false` reads as off. Sharing a Bot changes nothing there; the footer line says it is on by default for each of the owner's Bots on every turn, and that a friend's own Bot never gets it (zh: `默认对你的每个 Bot 开启…`). The "also when others use it" switch is gone (#601). The same switch per connector is the **Connectors** card in each Bot's settings (`BotConnectorsCard`, shown only once a connector is connected). Connector tool notices from DSH (`Tool added/removed: mcp__github__*` / `mcp__composio__*`) are not drawn in the chat. +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. + ### 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