From 942f934b0f58358d3f980c1d8b7e9dd07b33f99d Mon Sep 17 00:00:00 2001 From: Birfy Date: Sun, 18 Oct 2026 20:13:23 +0800 Subject: [PATCH 1/2] Configure the Bot Social server in Settings (#337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Bot Social shows the server, where it comes from and the connection, and switches to another server live. The address is validated (https, http only for localhost) and checked against /v1/platform before anything is saved; a switch needs a confirm that says a new server is a new identity. Each server has its own credentials, settings and per-Bot identities; the old credentials file is kept, never deleted, and switching back restores it. A failed switch puts the previous server back. cordis.patch.yml stays the default. Plugins add Settings pages through the new flybot.settings.page slot. --- README.md | 2 + README.zh.md | 2 + .../337-social-server-settings.added.md | 1 + core/client.js | 49 ++- core/src/client/flybot-registry.js | 2 +- core/src/client/flybot-slots.js | 13 + core/src/client/i18n.js | 29 ++ core/src/client/settings.js | 14 +- core/test/flybot-client.test.js | 2 +- core/test/settings-page-slot.test.js | 61 +++ core/types/flybot.d.ts | 11 + docs/subsystems/core-client.md | 1 + docs/subsystems/plugin-runtime.md | 1 + docs/subsystems/social.md | 10 +- patches/ds-bot/0001-core-fixes.patch | 36 ++ patches/ds-bot/0008-client-bundle.patch | 67 +++- patches/ds-bot/0014-flybot-branding.patch | 43 ++- .../files/src/client/flybot-registry.js | 2 +- .../ds-bot/files/src/client/flybot-slots.js | 13 + .../ds-bot/files/test/flybot-client.test.js | 2 +- .../files/test/settings-page-slot.test.js | 61 +++ patches/ds-bot/files/types/flybot.d.ts | 11 + plugins/social/README.md | 6 + plugins/social/client.js | 99 +++++ plugins/social/cordis.patch.yml | 3 +- plugins/social/index.js | 44 ++- plugins/social/src/server-config.js | 278 ++++++++++++++ plugins/social/test/social.test.js | 348 +++++++++++++++++- 28 files changed, 1169 insertions(+), 42 deletions(-) create mode 100644 changelog.d/337-social-server-settings.added.md create mode 100644 core/test/settings-page-slot.test.js create mode 100644 patches/ds-bot/files/test/settings-page-slot.test.js create mode 100644 plugins/social/src/server-config.js diff --git a/README.md b/README.md index 556e163d1..de4ff017a 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,8 @@ Point FlyBot at that server in `~/.dsh/profiles/web/cordis.patch.yml` (or `$DSH_ On first start FlyBot calls `POST /v1/onboard/flybot`, registers your home (`@.home`), and stores credentials in `$DSH_HOME/bot/flybot-social.json` (mode 600). No Bot is shared until you grant it. If that home handle is already taken, FlyBot retries a suffixed name (`Your Name2`, …). Sidebar **+** → **Copy my handle** copies the connected handle. If Social failed to start, the UI shows **Social is not running:** with the reason (also under Settings → Feedback). +**Change the server later** without editing YAML: Settings → **Bot Social** shows the server and its connection status, **Change server** checks the new address (`https://`, or `http://localhost` for development; FlyBot reads `/v1/platform` and shows its features) and, after a clear warning, switches without a restart. A new server is a **new identity**: your friends and sharing stay on the old server, your old credentials file is kept as a backup and never deleted, and switching back restores them. The `cordis.patch.yml` value stays the default. + **Share one Bot:** that Bot's settings → **Sharing** → **Invite to this Bot** (or **Copy invite link**); send the link. Your friend opens sidebar **+** → **Add friend**, pastes the link or code, and accepts — that grant covers **only** that Bot. **People with access** lists who has it; **Revoke** removes them. **Chat:** click the friend's Bot in the sidebar. Their messages arrive at once. What your Bots send back waits in that chat (Approve / Reject) unless **Auto-send** is on for that friend (friend Bot settings; default off). Look for the **to approve** badge if they see no reply. diff --git a/README.zh.md b/README.zh.md index 7976eb18e..b2e042ecb 100644 --- a/README.zh.md +++ b/README.zh.md @@ -149,6 +149,8 @@ export PORT=8787 首次启动会调用 `POST /v1/onboard/flybot`,登记你家(`@<名字>.home`),凭证写在 `$DSH_HOME/bot/flybot-social.json`(权限 600)。在你授权之前,没有任何 Bot 被共享。若家句柄已被占用,会自动试 `你的名字2`…。侧栏 **+** → **复制我的 handle** 复制当前已连接的句柄。若 Social 没起来,界面会显示 **Social is not running:** 及原因(也会进 Settings → Feedback)。 +**之后换服务器**不用改 YAML:设置 → **Bot Social** 显示当前服务器和连接状态,**更换服务器**会先检查新地址(`https://`;开发时可用 `http://localhost`;FlyBot 读取 `/v1/platform` 并列出它的功能),经过明确的警告确认后无需重启即可切换。换服务器等于**换一个新身份**:好友和分享留在旧服务器上,旧的凭证文件作为备份保留、不会被删除,切换回去即可恢复。`cordis.patch.yml` 里的值仍是默认值。 + **分享某一个 Bot:** 该 Bot 设置 → **Sharing** → **Invite to this Bot**(或 **复制邀请链接**),把链接发给对方;对方侧栏 **+** → **添加好友** 粘贴链接或码并接受——**只授权这一个 Bot**。**People with access** 可查看并 **Revoke**。 **私聊:** 点侧栏里对方的 Bot。对方来信立刻到。你这边发出去的内容停在该聊天等批准(除非对该好友开了 **Auto-send**,默认关)。对方看不到回复时,先看 **to approve / 待批准** 角标。 diff --git a/changelog.d/337-social-server-settings.added.md b/changelog.d/337-social-server-settings.added.md new file mode 100644 index 000000000..f9d3dc961 --- /dev/null +++ b/changelog.d/337-social-server-settings.added.md @@ -0,0 +1 @@ +Settings → Bot Social shows the Bot Social server FlyBot uses and its connection, and switches to another server without editing `cordis.patch.yml` or restarting ([#337](https://github.com/ThinkFlowLab/FlyBot/issues/337)). The address must be `https://` (`http://` only for localhost), is checked against `/v1/platform` (its features are shown) before anything is saved, and a switch needs a confirm that says a new server is a new identity: friends and sharing stay on the old server, the old credentials file is kept and never deleted, and switching back restores them. A failed switch puts the previous server back. The profile value stays the default; with nothing configured nothing changes. Plugins can add Settings pages through the new `flybot.settings.page` slot. diff --git a/core/client.js b/core/client.js index 05969e610..55f55a30a 100644 --- a/core/client.js +++ b/core/client.js @@ -182,6 +182,35 @@ var ZH = { "Connecting…": "正在连接…", "Could not connect: {error}": "连接失败:{error}", "Connected as {account}": "已连接:{account}", + // Settings → Bot Social (@flybot/social, #337) + "Bot Social": "Bot Social", + "Bot Social server": "Bot Social 服务器", + "Connected as {handle}": "已连接:{handle}", + "No server set. Friends and sharing are off until you set one.": "还没有设置服务器。设置之前,好友和分享功能不可用。", + "Set here": "在这里设置", + "Profile default": "配置文件默认值", + "Not set": "未设置", + "No server": "没有服务器", + "Change server": "更换服务器", + "Features": "功能", + "No optional features listed.": "服务器没有列出可选功能。", + "Reachability": "连通性", + "Read-only": "只读", + "Another FlyBot window holds this team. Change the server there.": "另一个 FlyBot 窗口正在使用这个团队,请在那里更换服务器。", + "Switch server": "切换服务器", + "Server address": "服务器地址", + "Used before:": "用过的服务器:", + "{host} (profile default)": "{host}(配置文件默认值)", + "Name for your home (optional)": "你的主页名称(可选)", + "Switching servers changes who you are": "换服务器,就是换一个身份", + "You used this server before. Your saved identity, friends and settings for it come back.": "你以前用过这台服务器,它上面保存的身份、好友和设置会恢复。", + "You get a new identity on this server. The friends and sharing you have now stay on the current server; they do not move.": "你会在这台服务器上得到一个新身份。现在的好友和分享留在当前服务器上,不会搬过去。", + "Your current sign-in is kept as a backup on this computer and is never deleted. Switch back any time.": "当前的登录凭据会作为备份保留在这台电脑上,不会被删除。随时可以切换回来。", + "Your Bots stop being shared with friends until you share them again on the new server.": "在新服务器上重新分享之前,你的 Bot 不再与好友共享。", + "This is the server you already use.": "这就是你正在用的服务器。", + "Switching…": "正在切换…", + "Switch to this server": "切换到这台服务器", + "Check": "检查", Connected: "已连接", Disconnect: "断开", Connect: "连接", @@ -1319,7 +1348,7 @@ function ungrantedLocalFix(details) { } // src/client/flybot-registry.js -var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.session.dock", "flybot.new-chat.action", "flybot.account.header"]; +var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.session.dock", "flybot.new-chat.action", "flybot.account.header", "flybot.settings.page"]; function createRegistry() { const entries2 = new Map(SLOTS.map((name) => [name, []])); const listeners2 = /* @__PURE__ */ new Set(); @@ -1910,6 +1939,12 @@ var SlotBoundary = class extends import_react2.Component { function Safe({ entry, props }) { return (0, import_react2.createElement)(SlotBoundary, { id: entry.id }, (0, import_react2.createElement)(entry.component, props)); } +function useSettingsPages() { + return useSlot("flybot.settings.page"); +} +function SettingsPageSlot({ entry, props }) { + return (0, import_react2.createElement)(Safe, { entry, props }); +} function useSidebarStructure() { return useSlot("flybot.sidebar.structure")[0]; } @@ -12349,9 +12384,12 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false }); const bot = page === "bots" && id ? roster.byId[id] : void 0; const room2 = page === "groups" && id ? roster.roomsById[id] : void 0; - const shown = SETTINGS_PAGES.some(([key]) => key === page) ? page : "general"; + const extra = useSettingsPages().map((entry) => ({ entry, key: `plugin:${entry.id}` })); + const pages = SETTINGS_PAGES.flatMap((item) => item[0] === "connectors" ? [item, ...extra.map(({ entry, key }) => [key, entry.label ?? entry.id])] : [item]); + const shown = pages.some(([key]) => key === page) ? page : "general"; + const pluginPage = extra.find(({ key }) => key === shown)?.entry; const usageFocus = shown === "usage" && id ? id : null; - const label = t(SETTINGS_PAGES.find(([key]) => key === shown)[1]); + const label = t(pages.find(([key]) => key === shown)[1]); const back = bot ? t("Back to Bots") : room2 ? t("Back to group chats") : usageFocus ? t("All usage") : null; const nav = useGlide(shown, { axis: "y" }); const pageKey = `${shown}:${bot?.id ?? room2?.id ?? ""}`; @@ -12374,13 +12412,13 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false "nav", { ref: nav.box, className: "bt-settings-nav", "aria-label": t("Settings pages") }, (0, import_react29.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }), - SETTINGS_PAGES.map(([key, text]) => (0, import_react29.createElement)("button", { + pages.map(([key, text]) => (0, import_react29.createElement)("button", { key, type: "button", className: "bt-settings-item", "aria-current": shown === key ? "page" : void 0, onClick: () => actions.openSettings(key) - }, (0, import_react29.createElement)(PAGE_ICONS[key]), t(text))), + }, (0, import_react29.createElement)(extra.find((item) => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text))), (0, import_react29.createElement)("button", { type: "button", className: "bt-settings-item bt-settings-shell", @@ -12410,6 +12448,7 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false room2 ? (0, import_react29.createElement)("div", { className: "bt-settings-form" }, (0, import_react29.createElement)(GroupSettingsForm, { key: room2.id, room: room2, roster, actions, run })) : null, shown === "groups" && !room2 ? (0, import_react29.createElement)(GroupsPage, { roster, actions, run }) : null, shown === "connectors" ? (0, import_react29.createElement)(ConnectorsPage, { roster, actions }) : null, + pluginPage ? (0, import_react29.createElement)(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call: call2, run, t, Section: SetSection, Row: SetRow } }) : null, shown === "capabilities" ? (0, import_react29.createElement)(CapabilitiesPage, { roster, actions, run }) : null, shown === "schedules" ? (0, import_react29.createElement)(SchedulesPage, { roster, actions, start: id ?? void 0 }) : null, shown === "secrets" ? (0, import_react29.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null, diff --git a/core/src/client/flybot-registry.js b/core/src/client/flybot-registry.js index 3e5174a35..7f1f110f6 100644 --- a/core/src/client/flybot-registry.js +++ b/core/src/client/flybot-registry.js @@ -1,6 +1,6 @@ // The FlyBot slot registry, free of React so the "@" menu and tests can use it. // See flybot-slots.js for what each slot carries. -export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header'] +export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page'] export function createRegistry() { const entries = new Map(SLOTS.map(name => [name, []])) diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index bfb32938b..bce0f8586 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -17,6 +17,9 @@ // approval ids it shows: the overlay then draws no second card for them (#260) // flybot.new-chat.action { id, order, label, hint?, visible?, run({ close, actions, roster }) } a row in the sidebar '+' menu (next to Create group chat; Face-to-face group, Add friend, Friend requests) // flybot.account.header { id, order, component({ actions, call }) } top of the account menu (the owner's own Bot Social handle) +// flybot.settings.page { id, order, label, icon?, component({ roster, actions, call, run, t, Section, Row }) } a page in the Settings list +// (after Connectors); `label` is English and goes through the host's t(); the component gets the host's t(), +// Section(title) and Row(label, hint) so the page looks like the others and reads zh in a zh UI // // The same registry object also carries the host Bot look so a plugin can draw a Bot // exactly as the sidebar does: __flybot__.BotAvatar / BotMark / lookOf (set in registry()). @@ -73,6 +76,16 @@ function Safe({ entry, props }) { return h(SlotBoundary, { id: entry.id }, h(entry.component, props)) } +// Pages plugins add to Settings (flybot.settings.page), in order. +export function useSettingsPages() { + return useSlot('flybot.settings.page') +} + +// Renders a plugin's Settings page without letting its failure close the dialog. +export function SettingsPageSlot({ entry, props }) { + return h(Safe, { entry, props }) +} + // The first 'flybot.sidebar.structure' entry, or undefined: the sidebar then lists Bots flat. export function useSidebarStructure() { return useSlot('flybot.sidebar.structure')[0] diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 307e8e881..4f81fbfc2 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -146,6 +146,35 @@ const ZH = { 'Connecting…': '正在连接…', 'Could not connect: {error}': '连接失败:{error}', 'Connected as {account}': '已连接:{account}', + // Settings → Bot Social (@flybot/social, #337) + 'Bot Social': 'Bot Social', + 'Bot Social server': 'Bot Social 服务器', + 'Connected as {handle}': '已连接:{handle}', + 'No server set. Friends and sharing are off until you set one.': '还没有设置服务器。设置之前,好友和分享功能不可用。', + 'Set here': '在这里设置', + 'Profile default': '配置文件默认值', + 'Not set': '未设置', + 'No server': '没有服务器', + 'Change server': '更换服务器', + 'Features': '功能', + 'No optional features listed.': '服务器没有列出可选功能。', + 'Reachability': '连通性', + 'Read-only': '只读', + 'Another FlyBot window holds this team. Change the server there.': '另一个 FlyBot 窗口正在使用这个团队,请在那里更换服务器。', + 'Switch server': '切换服务器', + 'Server address': '服务器地址', + 'Used before:': '用过的服务器:', + '{host} (profile default)': '{host}(配置文件默认值)', + 'Name for your home (optional)': '你的主页名称(可选)', + 'Switching servers changes who you are': '换服务器,就是换一个身份', + 'You used this server before. Your saved identity, friends and settings for it come back.': '你以前用过这台服务器,它上面保存的身份、好友和设置会恢复。', + 'You get a new identity on this server. The friends and sharing you have now stay on the current server; they do not move.': '你会在这台服务器上得到一个新身份。现在的好友和分享留在当前服务器上,不会搬过去。', + 'Your current sign-in is kept as a backup on this computer and is never deleted. Switch back any time.': '当前的登录凭据会作为备份保留在这台电脑上,不会被删除。随时可以切换回来。', + 'Your Bots stop being shared with friends until you share them again on the new server.': '在新服务器上重新分享之前,你的 Bot 不再与好友共享。', + 'This is the server you already use.': '这就是你正在用的服务器。', + 'Switching…': '正在切换…', + 'Switch to this server': '切换到这台服务器', + 'Check': '检查', Connected: '已连接', Disconnect: '断开', Connect: '连接', diff --git a/core/src/client/settings.js b/core/src/client/settings.js index 68bcf5826..9a9e2ee9b 100644 --- a/core/src/client/settings.js +++ b/core/src/client/settings.js @@ -5,6 +5,7 @@ import { allThemes } from './themes.js' import { CloseIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, ConnectorIcon, InfoIcon, PlugIcon, PlusIcon, SlidersIcon, SETTINGS_ICONS } from './icons.js' import { AdminStar, BotAvatar, RoomAvatar } from './mark.js' import { openHarnessSettings } from './sidebar.js' +import { SettingsPageSlot, useSettingsPages, call } from './flybot-slots.js' import { useEscape } from './overlay-hooks.js' import { BotSettingsForm } from './bot-settings.js' import { Segmented, useGlide } from './motion.js' @@ -161,9 +162,13 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = // A Bot or group deleted while its form is open leaves its page on the list. const bot = page === 'bots' && id ? roster.byId[id] : undefined const room = page === 'groups' && id ? roster.roomsById[id] : undefined - const shown = SETTINGS_PAGES.some(([key]) => key === page) ? page : 'general' + // Plugin pages (flybot.settings.page) sit after Connectors; a page whose plugin is gone falls back to General. + const extra = useSettingsPages().map(entry => ({ entry, key: `plugin:${entry.id}` })) + const pages = SETTINGS_PAGES.flatMap(item => (item[0] === 'connectors' ? [item, ...extra.map(({ entry, key }) => [key, entry.label ?? entry.id])] : [item])) + const shown = pages.some(([key]) => key === page) ? page : 'general' + const pluginPage = extra.find(({ key }) => key === shown)?.entry const usageFocus = shown === 'usage' && id ? id : null - const label = t(SETTINGS_PAGES.find(([key]) => key === shown)[1]) + const label = t(pages.find(([key]) => key === shown)[1]) const back = bot ? t('Back to Bots') : room ? t('Back to group chats') : usageFocus ? t('All usage') : null const nav = useGlide(shown, { axis: 'y' }) // A page enters from the right when it is one level in, from the left when it comes @@ -186,9 +191,9 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = h('div', { className: 'bt-settings', role: 'dialog', 'aria-modal': true, 'aria-label': t('FlyBot settings'), 'data-wide': shown === 'usage' ? '' : undefined }, h('nav', { ref: nav.box, className: 'bt-settings-nav', 'aria-label': t('Settings pages') }, h('span', { ref: nav.thumb, className: 'bt-thumb', 'aria-hidden': true }), - SETTINGS_PAGES.map(([key, text]) => h('button', { + pages.map(([key, text]) => h('button', { key, type: 'button', className: 'bt-settings-item', 'aria-current': shown === key ? 'page' : undefined, onClick: () => actions.openSettings(key), - }, h(PAGE_ICONS[key]), t(text))), + }, h(extra.find(item => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text))), h('button', { type: 'button', className: 'bt-settings-item bt-settings-shell', title: t('Models, providers, and the rest of DeepSeek Harness'), onClick: () => { close(); openHarnessSettings() }, @@ -205,6 +210,7 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = room ? h('div', { className: 'bt-settings-form' }, h(GroupSettingsForm, { key: room.id, room, roster, actions, run })) : null, shown === 'groups' && !room ? h(GroupsPage, { roster, actions, run }) : null, shown === 'connectors' ? h(ConnectorsPage, { roster, actions }) : null, + pluginPage ? h(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call, run, t, Section: SetSection, Row: SetRow } }) : null, shown === 'capabilities' ? h(CapabilitiesPage, { roster, actions, run }) : null, shown === 'schedules' ? h(SchedulesPage, { roster, actions, start: id ?? undefined }) : null, shown === 'secrets' ? h(SecretsPage, { roster, actions, run, Section: SetSection }) : null, diff --git a/core/test/flybot-client.test.js b/core/test/flybot-client.test.js index 566a1f5b9..464384311 100644 --- a/core/test/flybot-client.test.js +++ b/core/test/flybot-client.test.js @@ -15,7 +15,7 @@ test('the client registry orders entries, replaces by id, and notifies', () => { // The replaced entry stays: disposing the old one does not remove its successor. assert.deepEqual(slots.entries('flybot.sidebar.section').map(e => e.id), ['b', 'a']) assert.throws(() => slots.register('nope', { id: 'x' }), /unknown slot/) - assert.deepEqual(SLOTS, ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header']) + assert.deepEqual(SLOTS, ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page']) }) test('new-chat action rows sit next to Create group chat and skip a failing plugin', () => { diff --git a/core/test/settings-page-slot.test.js b/core/test/settings-page-slot.test.js new file mode 100644 index 000000000..36c617700 --- /dev/null +++ b/core/test/settings-page-slot.test.js @@ -0,0 +1,61 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// #337: a plugin adds a page to Settings (flybot.settings.page), after Connectors. +const { outputFiles } = await build({ + stdin: { + contents: "export { SettingsDialog } from './src/client/settings.js'; export { registry } from './src/client/flybot-registry.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 } }) +const react = { createElement: h, Component: class {}, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {}, useState: v => [typeof v === 'function' ? v() : v, () => {}], useMemo: f => f(), useRef: current => ({ current }), useCallback: f => f, useSyncExternalStore: (_s, get) => get() } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, document: { hidden: false, addEventListener() {}, removeEventListener() {} }, navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, + window: { addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) }, + 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}`) + }, + console, setTimeout, clearTimeout, requestAnimationFrame: () => 0, cancelAnimationFrame() {}, +}) +const { SettingsDialog, registry } = module.exports +const all = (node, out = []) => { if (node && typeof node === 'object') { out.push(node); for (const c of [node.props?.children].flat(Infinity)) all(c, out) } return out } +const text = node => (typeof node === 'string' ? node : [node?.props?.children].flat(Infinity).map(text).join('')) +const roster = { bots: [], rooms: [], byId: {}, roomsById: {}, prefs: {}, mainBotIds: [], connectors: [] } +const actions = { openSettings() {}, closeSettings() {} } + +test('a flybot.settings.page entry is listed after Connectors and drawn with the host t, Section, Row and call', () => { + const off = registry().register('flybot.settings.page', { id: 'social', order: 100, label: 'Bot Social', component: () => null }) + try { + const tree = SettingsDialog({ page: 'plugin:social', id: null, roster, actions, useUpdate: () => ({}) }) + const nodes = all(tree) + const items = nodes.filter(node => node.type === 'button' && node.props.className === 'bt-settings-item').map(text) + assert.deepEqual(items.slice(3, 6), ['Connectors', 'Bot Social', 'Capabilities']) + assert.equal(text(nodes.find(node => node.type === 'h2')), 'Bot Social') + const slot = nodes.find(node => node.props?.entry?.id === 'social') + assert.ok(slot) + assert.deepEqual(Object.keys(slot.props.props).sort(), ['Row', 'Section', 'actions', 'call', 'roster', 'run', 't'].sort()) + assert.equal(slot.props.props.t('Bot Social'), 'Bot Social') + assert.equal(typeof slot.props.props.call, 'function') + } finally { + off() + } +}) + +test('a page whose plugin is gone falls back to General, and without plugins the list is unchanged', () => { + const tree = SettingsDialog({ page: 'plugin:social', id: null, roster, actions, useUpdate: () => ({}) }) + const nodes = all(tree) + assert.equal(text(nodes.find(node => node.type === 'h2')), 'General') + const items = nodes.filter(node => node.type === 'button' && node.props.className === 'bt-settings-item').map(text) + assert.deepEqual(items.slice(0, 6), ['General', 'Bots', 'Group chats', 'Connectors', 'Capabilities', 'Scheduled tasks']) + assert.equal(nodes.some(node => node.props?.entry !== undefined), false) +}) diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index de5f6ca4c..ae18d726c 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -464,6 +464,9 @@ export type FlyBotCall = (endpoint: string, payload?: unknown) => Promise * Social replies waiting for approval). * - `flybot.account.header`: a compact block at the top of the account menu (the social * plugin shows the owner's own Bot Social handle with a copy button). + * - `flybot.settings.page`: a page in the Settings list, after Connectors. `label` is an English + * key looked up with the host `t()`; the component receives the host `t`, `Section(title)` and + * `Row(label, hint)` so it matches the other pages and reads in the DSH language. * - `flybot.new-chat.action`: a row in the sidebar '+' new-chat menu, next to * Create group chat (e.g. Face-to-face group, Add friend). `label` / `hint` are English keys * looked up with the same `t()` map as the neighbouring rows. @@ -484,6 +487,14 @@ export interface FlyBotSlots { 'flybot.room.overview': { id: string; order?: number; component(props: { room: any; roster: any; actions: any; call: FlyBotCall }): any } 'flybot.session.dock': { id: string; order?: number; /** The `by` of the approvals this dock draws; the overlay then draws no second card for the ids it claims. */ policy?: string; component(props: { sessionId?: string; roster?: any; room?: any; bot?: any; actions?: any; call: FlyBotCall; /** Report the approval ids this dock shows (a plugin's own policy). */ claim?: (ids: string[]) => void }): any } 'flybot.account.header': { id: string; order?: number; component(props: { actions?: any; call: FlyBotCall }): any } + 'flybot.settings.page': { + id: string + order?: number + label: string + /** A React component drawn beside the label in the Settings list (default: the connector mark). */ + icon?: any + component(props: { roster: any; actions: any; call: FlyBotCall; run(promise: Promise): Promise; t(key: string, params?: Record): string; Section: any; Row: any }): any + } 'flybot.new-chat.action': { id: string order?: number diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 2166c6fee..8306294e6 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -99,6 +99,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - The sidebar footer button (`ConnectApps` in `sidebar.js`, label **Connect apps** / 连接应用) opens Settings > Connectors through `actions.openConnectors()`, the same call as the account menu's **Connectors** row. The shell's plugin panel stays reachable in the Bot surface through the account menu's **Plugins** row (`actions.selectPanel('plugins')`, 插件), without switching to classic Agent mode. - Create Bot starts on the Main Bot's model (#230): `startModel()` (`bot-draft.js`) picks the Main Bot's chosen model, else the one it last ran on, when it is served; then `roster.defaultModel`, then the first served model. Only served models are picked, so a provider without a credential is never preselected. - Settings say each thing once (#230): the Model and Main Bot cards show no row label that repeats the card title (`SwitchRow` `showLabel: false` keeps the switch's accessible name), the Create Bot dialog has its Back / Close in the header only (the footer Back / Cancel is for the inline form), and the account menu is as wide as its longest item (`width:max-content`, capped to the viewport) instead of cutting `Switch to classic Agent mode`. +- Slot `flybot.settings.page` (`0003-flybot-api-client`, types in `flybot.d.ts`, #337) adds a page to the Settings list right after Connectors: `{ id, order, label, icon?, component }`. `SettingsDialog` (`settings.js`) lists `useSettingsPages()` entries under the key `plugin:` and draws the component through `SettingsPageSlot` (a failing component draws nothing, it does not close the dialog); a page whose plugin is gone opens General. `label` is an English key for the host `t()`; the component gets `{ roster, actions, call, run, t, Section, Row }` (the host translator, the Settings section card and row, so the page matches the others and reads zh from `i18n.js`). Social registers **Bot Social** (the server, see [social](social.md)). Test: `core/test/settings-page-slot.test.js`. - Slot `flybot.account.header` (`0003-flybot-api-client`) is a compact block at the top of the account menu (`FlyBotAccountHeader`); social uses it for the owner's own handle (#181). - Slot `flybot.new-chat.action` (`0003-flybot-api-client`, types in `flybot.d.ts`) is a row on the sidebar **+** new-chat menu, next to Create group chat. Plugins register `{ id, order, label, hint?, visible?, run }`; the picker looks `label` / `hint` up with `t()` like the neighbouring rows. A string returned from `run` is shown as a toast (`actions.notify`). Social registers Face-to-face group and Add friend; see [plugin-runtime](plugin-runtime.md) and [social](social.md). - Sidebar **+** (`mode === 'new'`) opens a short action menu (`data-menu`, no To:/composer) until **New chat** or **Create group chat**. That menu does not list individual Bots (Chief and the rest stay behind **New chat**). The Agent row in that FlyBot menu is **Add coding agent**; **Add DSH Agent** stays the DSH product string on Agent rows elsewhere (#126, #181). diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 0aad75216..1ca879952 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -69,6 +69,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots - `hooks.js` hands a transport `approved: true` (never read from the message) when the owner approved that send on a FlyBot card; social uses it to finish Bot Social's own hold with the same click (#258). - A `flybot.session.dock` entry may set `policy` (the `by` of the approvals it draws; social registers `policy: 'social'`) and receives `claim(ids)`: call it with the approval ids the dock shows so the overlay draws no second card for them (#260). The host also marks the chat each mounted session dock belongs to as open (`chatOpened`), and the overlay draws no pointer for an approval of the open chat (#271). The pointer's chat is the approval's room, else the friend's chat it is addressed to, else its sender Bot (#278). - Social registers a second `flybot.session.dock` entry, `social-room-end` (`FaceRoomStatus`, order 40): the plain ended-room line for a left/closed face room (#191). +- Social registers `flybot.settings.page` (`SocialServerSettings`, label **Bot Social**): the server address, its status and the switch form (#337). The host side is `social/server` (a read) and `social/server-check` / `social/server-set` (writes: blocked on a read-only host and, like every plugin write, while the team has more than one member). - Social also registers `flybot.account.header` (`MyHandle`): my own Bot Social handle plus Copy at the top of the account menu (#181), hidden while not connected. - Social registers two `flybot.new-chat.action` rows (Face-to-face group, Add friend). Each `run` closes the picker. Copying an invite and seeing your own handle happen inside the Add friend panel (#181). Still not team mode. - A friend's Bot opens **Bot settings** through core `remote-bot-settings` (`openSettings('bots', id)`): a compact sheet (`data-flybot=remote-bot-sheet`) with `FlyBotDetails` from slot `flybot.bot.details`, not the local Bot editor. The sheet stacks above its scrim like the host settings overlay (#166). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 609cf3612..3e5ab678d 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -28,12 +28,13 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers | --- | --- | | `plugins/social/index.js` | Plugin entry. | | `plugins/social/src/social.js` | Host: roster, grants, inbox, `social/status` pending-approval queue, Approve / Reject, hide-remote, Manage access, shared-room approvals provider, face-to-face join/poll/leave/start/send and `social/face-auto`. | +| `plugins/social/src/server-config.js` | Which server this FlyBot uses (#337): address validation (`parseServerUrl`), the `GET /v1/platform` probe (`probeServer`), and `createServerController`, which starts the social layer for the effective server and switches it live. `index.js` wires it and registers `social/server`, `social/server-check`, `social/server-set`. | | `plugins/social/src/mcp-client.js` | Owner MCP client plus isolated grant/invite REST fallbacks (`listGrants`, `revokeGrant`, `updateBotProfile`, `declineGrant`, invites). | | `plugins/social/src/look.js` | Map Bot Social profile look fields to FlyBot `{ color, avatar }` (optional on older servers). | | `plugins/social/src/face-rooms.js` | Face-to-face REST client (join/list/get/start/leave/messages), error mapping, event rendering, peer-string sanitization. Auto-send is MCP in `social.js`. | | `plugins/social/src/presence.js` | "Name is thinking…" for friends' Bots (#297): sends `set_presence` for my Bots' turns that answer a friend or run in an open face room (`turn.start` / `turn.end`, heartbeat), polls `list_presence` only while a social chat asks (`social/typing`), clears a line when that Bot's message arrives. Gated on the `presence` feature. | | `plugins/social/src/face-title.js` | Face-to-face room titles (`Face-to-face · Alice, Bob · 482913`). Ships in the plugin so a packaged install can import (#153); core keeps its own copy for hosted rooms. | -| `plugins/social/client.js` | `PeerThinking` dock line (`social-thinking`, order 60: one `bt-shimmer` line `Name is thinking…`). Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face group / Add friend (own handle line, per-Bot **Copy invite link**); in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid (6-digit code, then Leave / Enter chat); Bot settings Invite / Manage access, or Auto-send + Remove this Bot on a friend's Bot. | +| `plugins/social/client.js` | Settings → **Bot Social** (`SocialServerSettings`, slot `flybot.settings.page`); `PeerThinking` dock line (`social-thinking`, order 60: one `bt-shimmer` line `Name is thinking…`). Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face group / Add friend (own handle line, per-Bot **Copy invite link**); in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid (6-digit code, then Leave / Enter chat); Bot settings Invite / Manage access, or Auto-send + Remove this Bot on a friend's Bot. | ## Entry points @@ -118,6 +119,13 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - **#140:** Auto-send defaults off. Accepting a friend request uses that relationship's peer home for Auto-send, not the first roster entry. The owner sees Approve / Reject in the chat, not “Waiting for the owner's approval.” Composer drafts are labelled You. - **#136 (suffix):** Onboard retries truncate the sanitized handle body so `Name2` / `Name3` still change within the 32-character `homeHandleOf` limit. +### The server in Settings (#337) + +- **Where it comes from.** `config.server` in the profile patch is the default. A server picked in Settings → **Bot Social** is kept in `flybot-social-server.json` (mode 600, next to the credentials file) and wins while it exists; choosing the default again removes the file. Nothing configured anywhere behaves as before (the start fails with `server URL is required`, `Social is not running: …`; the page says **No server set**). The page reads `social/server` (a read; `{ server, source: settings|config|none, defaultServer, state: connected|connecting|not-configured|error, handle, error, canChange, known[], platform? }`: never a token) and calls `social/server-check` (it makes the host fetch an address the caller names, so it follows the write rules like `social/server-set`). +- **Validation.** `parseServerUrl`: `https://` only, `http://` just for `localhost`, `127.0.0.1` and `[::1]`; no user name or password, query or fragment; at most 200 characters; an address without a scheme gets `https://` (`http://` for those local hosts). `probeServer` GETs `/v1/platform` without credentials, 8 s deadline, `redirect: 'manual'` (a redirect is refused, so the saved address is the checked one); the answer must look like Bot Social (a `name`, a `platform_key` or a `features` list) and its `features` are shown (cleaned, at most 40). `social/server-set` repeats both before it touches anything, so a UI that skipped **Check** gains nothing. +- **A server is an identity.** The default server keeps its files. Any other server gets `flybot-social.json.` (credentials; the same suffix `startSocial` already used for backups, `tag` = first 8 hex of the sha-256 of the normalized address) and `flybot-social-settings..json`; a different server never reads another's file, and no credentials file is ever deleted or rewritten by a switch. The per-Bot parts that live in host state (the `member` identity, the `autoApprove` marks and the person/room grants, which are Bot Social ids and handles) are parked in `flybot-social-bots..json` (no token) and cleared before the new server starts, so a stale grant to `@bob.home` can never apply to a different Bob on the new server; switching back restores them and the saved credentials, without a second onboarding. The old server's friends' Bots leave the list when the new server's first roster fetch replaces it (`remote.publish`); face-to-face rooms already hosted stay as local transcripts, but nothing polls them. +- **Applied live, with a way back.** `social/server-set` (needs `confirm: true`; a write, so a read-only host and a multi-member team refuse it) stops the social layer, parks the old server's Bot identities, saves the choice, restores what the new server had, and starts again (first start onboards). If that start fails or takes over 45 s, the previous server's files, identities and choice are put back, it is started again, and the error says so; a start that finishes late is disposed. The Settings page says plainly that this is a new identity, that friends and sharing stay on the old server, and that the old sign-in is kept as a backup and never deleted. Tests: `plugins/social/test/social.test.js` (`Settings server`, `server controller`, `parseServerUrl`, `probeServer`). + ### Add friend invite input (#310) `social/join-invite` runs the box text through `parseInviteInput` (`social.js`) before `join_invite`. It takes `https://host/i/CODE`, the same without the scheme, a link whose `/i/` is missing (host plus one bare token), a bare code, or the whole copied invite text (the first token with `/i/` or a URL wins), and sends only the code: Bot Social's `join_invite` looks up a code (it strips just a leading `…/i/`), so no host is ever built or added. Copy invite link hands out the server's `invite.url` through `inviteLink`, which rebuilds `origin/i/CODE` if a server ever answered a url without `/i/`; Bot Social itself always emits `/i/CODE` (`inviteUrl`) and serves only that route, so a link without `/i/` comes from hand-trimming or a client that dropped it. A pasted host that is not `config.server` is kept as `host`; a not-found then says the link may come from another server. `mapJoinInviteError` turns `invite_not_found`, expired / revoked / used invites, an own invite, `blocked` and `rate_limited` into one short sentence each ("This invite link wasn't found. It may be incomplete, expired, or already used. Ask your friend for a new link."); anything else passes through unchanged. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index c6ea07f92..d05f33354 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -306,6 +306,42 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Automation tasks': '自动化任务', Connectors: '连接器', 'Search connectors': '搜索连接器', +@@ -93,6 +93,35 @@ const ZH = { + 'Connecting…': '正在连接…', + 'Could not connect: {error}': '连接失败:{error}', + 'Connected as {account}': '已连接:{account}', ++ // Settings → Bot Social (@flybot/social, #337) ++ 'Bot Social': 'Bot Social', ++ 'Bot Social server': 'Bot Social 服务器', ++ 'Connected as {handle}': '已连接:{handle}', ++ 'No server set. Friends and sharing are off until you set one.': '还没有设置服务器。设置之前,好友和分享功能不可用。', ++ 'Set here': '在这里设置', ++ 'Profile default': '配置文件默认值', ++ 'Not set': '未设置', ++ 'No server': '没有服务器', ++ 'Change server': '更换服务器', ++ 'Features': '功能', ++ 'No optional features listed.': '服务器没有列出可选功能。', ++ 'Reachability': '连通性', ++ 'Read-only': '只读', ++ 'Another FlyBot window holds this team. Change the server there.': '另一个 FlyBot 窗口正在使用这个团队,请在那里更换服务器。', ++ 'Switch server': '切换服务器', ++ 'Server address': '服务器地址', ++ 'Used before:': '用过的服务器:', ++ '{host} (profile default)': '{host}(配置文件默认值)', ++ 'Name for your home (optional)': '你的主页名称(可选)', ++ 'Switching servers changes who you are': '换服务器,就是换一个身份', ++ 'You used this server before. Your saved identity, friends and settings for it come back.': '你以前用过这台服务器,它上面保存的身份、好友和设置会恢复。', ++ 'You get a new identity on this server. The friends and sharing you have now stay on the current server; they do not move.': '你会在这台服务器上得到一个新身份。现在的好友和分享留在当前服务器上,不会搬过去。', ++ 'Your current sign-in is kept as a backup on this computer and is never deleted. Switch back any time.': '当前的登录凭据会作为备份保留在这台电脑上,不会被删除。随时可以切换回来。', ++ 'Your Bots stop being shared with friends until you share them again on the new server.': '在新服务器上重新分享之前,你的 Bot 不再与好友共享。', ++ 'This is the server you already use.': '这就是你正在用的服务器。', ++ 'Switching…': '正在切换…', ++ 'Switch to this server': '切换到这台服务器', ++ 'Check': '检查', + Connected: '已连接', + Disconnect: '断开', + Connect: '连接', @@ -105,17 +105,37 @@ const ZH = { 'GitHub addresses must start with https://': 'GitHub 地址必须以 https:// 开头', 'This DSH cannot load connectors': '这个 DSH 不能加载连接器', diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 66e2266ee..f2e1ccaa3 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -80,6 +80,42 @@ diff --git a/client.js b/client.js "Automation tasks": "自动化任务", Connectors: "连接器", "Search connectors": "搜索连接器", +@@ -129,6 +129,35 @@ var ZH = { + "Connecting…": "正在连接…", + "Could not connect: {error}": "连接失败:{error}", + "Connected as {account}": "已连接:{account}", ++ // Settings → Bot Social (@flybot/social, #337) ++ "Bot Social": "Bot Social", ++ "Bot Social server": "Bot Social 服务器", ++ "Connected as {handle}": "已连接:{handle}", ++ "No server set. Friends and sharing are off until you set one.": "还没有设置服务器。设置之前,好友和分享功能不可用。", ++ "Set here": "在这里设置", ++ "Profile default": "配置文件默认值", ++ "Not set": "未设置", ++ "No server": "没有服务器", ++ "Change server": "更换服务器", ++ "Features": "功能", ++ "No optional features listed.": "服务器没有列出可选功能。", ++ "Reachability": "连通性", ++ "Read-only": "只读", ++ "Another FlyBot window holds this team. Change the server there.": "另一个 FlyBot 窗口正在使用这个团队,请在那里更换服务器。", ++ "Switch server": "切换服务器", ++ "Server address": "服务器地址", ++ "Used before:": "用过的服务器:", ++ "{host} (profile default)": "{host}(配置文件默认值)", ++ "Name for your home (optional)": "你的主页名称(可选)", ++ "Switching servers changes who you are": "换服务器,就是换一个身份", ++ "You used this server before. Your saved identity, friends and settings for it come back.": "你以前用过这台服务器,它上面保存的身份、好友和设置会恢复。", ++ "You get a new identity on this server. The friends and sharing you have now stay on the current server; they do not move.": "你会在这台服务器上得到一个新身份。现在的好友和分享留在当前服务器上,不会搬过去。", ++ "Your current sign-in is kept as a backup on this computer and is never deleted. Switch back any time.": "当前的登录凭据会作为备份保留在这台电脑上,不会被删除。随时可以切换回来。", ++ "Your Bots stop being shared with friends until you share them again on the new server.": "在新服务器上重新分享之前,你的 Bot 不再与好友共享。", ++ "This is the server you already use.": "这就是你正在用的服务器。", ++ "Switching…": "正在切换…", ++ "Switch to this server": "切换到这台服务器", ++ "Check": "检查", + Connected: "已连接", + Disconnect: "断开", + Connect: "连接", @@ -141,17 +141,37 @@ var ZH = { "GitHub addresses must start with https://": "GitHub 地址必须以 https:// 开头", "This DSH cannot load connectors": "这个 DSH 不能加载连接器", @@ -445,7 +481,7 @@ diff --git a/client.js b/client.js +} + +// src/client/flybot-registry.js -+var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.session.dock", "flybot.new-chat.action", "flybot.account.header"]; ++var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.session.dock", "flybot.new-chat.action", "flybot.account.header", "flybot.settings.page"]; +function createRegistry() { + const entries2 = new Map(SLOTS.map((name) => [name, []])); + const listeners2 = /* @__PURE__ */ new Set(); @@ -675,7 +711,7 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,6 +1242,482 @@ function maskOf(shape) { +@@ -1242,6 +1242,488 @@ function maskOf(shape) { } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -924,6 +960,12 @@ diff --git a/client.js b/client.js +function Safe({ entry, props }) { + return (0, import_react2.createElement)(SlotBoundary, { id: entry.id }, (0, import_react2.createElement)(entry.component, props)); +} ++function useSettingsPages() { ++ return useSlot("flybot.settings.page"); ++} ++function SettingsPageSlot({ entry, props }) { ++ return (0, import_react2.createElement)(Safe, { entry, props }); ++} +function useSidebarStructure() { + return useSlot("flybot.sidebar.structure")[0]; +} @@ -9294,7 +9336,19 @@ diff --git a/client.js b/client.js useEscape(close); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); -@@ -10786,32 +10786,32 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false +@@ -10779,39 +10779,42 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false + }); + const bot = page === "bots" && id ? roster.byId[id] : void 0; + const room2 = page === "groups" && id ? roster.roomsById[id] : void 0; +- const shown = SETTINGS_PAGES.some(([key]) => key === page) ? page : "general"; ++ const extra = useSettingsPages().map((entry) => ({ entry, key: `plugin:${entry.id}` })); ++ const pages = SETTINGS_PAGES.flatMap((item) => item[0] === "connectors" ? [item, ...extra.map(({ entry, key }) => [key, entry.label ?? entry.id])] : [item]); ++ const shown = pages.some(([key]) => key === page) ? page : "general"; ++ const pluginPage = extra.find(({ key }) => key === shown)?.entry; + const usageFocus = shown === "usage" && id ? id : null; +- const label = t(SETTINGS_PAGES.find(([key]) => key === shown)[1]); ++ const label = t(pages.find(([key]) => key === shown)[1]); + const back = bot ? t("Back to Bots") : room2 ? t("Back to group chats") : usageFocus ? t("All usage") : null; const nav = useGlide(shown, { axis: "y" }); const pageKey = `${shown}:${bot?.id ?? room2?.id ?? ""}`; const depth = bot || room2 ? 1 : 0; @@ -9326,7 +9380,7 @@ diff --git a/client.js b/client.js - (0, import_react28.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }), - SETTINGS_PAGES.map(([key, text]) => (0, import_react28.createElement)("button", { + (0, import_react29.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }), -+ SETTINGS_PAGES.map(([key, text]) => (0, import_react29.createElement)("button", { ++ pages.map(([key, text]) => (0, import_react29.createElement)("button", { key, type: "button", className: "bt-settings-item", @@ -9334,12 +9388,12 @@ diff --git a/client.js b/client.js onClick: () => actions.openSettings(key) - }, (0, import_react28.createElement)(PAGE_ICONS[key]), t(text))), - (0, import_react28.createElement)("button", { -+ }, (0, import_react29.createElement)(PAGE_ICONS[key]), t(text))), ++ }, (0, import_react29.createElement)(extra.find((item) => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text))), + (0, import_react29.createElement)("button", { type: "button", className: "bt-settings-item bt-settings-shell", title: t("Models, providers, and the rest of DeepSeek Harness"), -@@ -10819,64 +10819,65 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false +@@ -10819,64 +10819,66 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false close(); openHarnessSettings(); } @@ -9381,6 +9435,7 @@ diff --git a/client.js b/client.js + room2 ? (0, import_react29.createElement)("div", { className: "bt-settings-form" }, (0, import_react29.createElement)(GroupSettingsForm, { key: room2.id, room: room2, roster, actions, run })) : null, + shown === "groups" && !room2 ? (0, import_react29.createElement)(GroupsPage, { roster, actions, run }) : null, + shown === "connectors" ? (0, import_react29.createElement)(ConnectorsPage, { roster, actions }) : null, ++ pluginPage ? (0, import_react29.createElement)(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call: call2, run, t, Section: SetSection, Row: SetRow } }) : null, + shown === "capabilities" ? (0, import_react29.createElement)(CapabilitiesPage, { roster, actions, run }) : null, + shown === "schedules" ? (0, import_react29.createElement)(SchedulesPage, { roster, actions, start: id ?? void 0 }) : null, + shown === "secrets" ? (0, import_react29.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null, diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index ce7929d5e..efa5bff71 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -153,6 +153,14 @@ diff --git a/src/client/diagnostics.js b/src/client/diagnostics.js diff --git a/src/client/settings.js b/src/client/settings.js --- a/src/client/settings.js +++ b/src/client/settings.js +@@ -5,6 +5,7 @@ import { allThemes } from './themes.js' + import { CloseIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, ConnectorIcon, InfoIcon, PlugIcon, PlusIcon, SlidersIcon, SETTINGS_ICONS } from './icons.js' + import { AdminStar, BotAvatar, RoomAvatar } from './mark.js' + import { openHarnessSettings } from './sidebar.js' ++import { SettingsPageSlot, useSettingsPages, call } from './flybot-slots.js' + import { useEscape } from './overlay-hooks.js' + import { BotSettingsForm } from './bot-settings.js' + import { Segmented, useGlide } from './motion.js' @@ -23,7 +23,7 @@ const capitalize = word => word[0].toUpperCase() + word.slice(1) // Settings dialog: a 198px page list beside the page. Bots and group chats each list @@ -193,7 +201,23 @@ diff --git a/src/client/settings.js b/src/client/settings.js h('span', { className: 'bt-set-bot' }, h(BotAvatar, { bot, size: 28, badge: false, live: false }), h('span', { className: 'bt-row-title' }, bot.name), -@@ -181,7 +181,7 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = +@@ -159,9 +159,13 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = + // A Bot or group deleted while its form is open leaves its page on the list. + const bot = page === 'bots' && id ? roster.byId[id] : undefined + const room = page === 'groups' && id ? roster.roomsById[id] : undefined +- const shown = SETTINGS_PAGES.some(([key]) => key === page) ? page : 'general' ++ // Plugin pages (flybot.settings.page) sit after Connectors; a page whose plugin is gone falls back to General. ++ const extra = useSettingsPages().map(entry => ({ entry, key: `plugin:${entry.id}` })) ++ const pages = SETTINGS_PAGES.flatMap(item => (item[0] === 'connectors' ? [item, ...extra.map(({ entry, key }) => [key, entry.label ?? entry.id])] : [item])) ++ const shown = pages.some(([key]) => key === page) ? page : 'general' ++ const pluginPage = extra.find(({ key }) => key === shown)?.entry + const usageFocus = shown === 'usage' && id ? id : null +- const label = t(SETTINGS_PAGES.find(([key]) => key === shown)[1]) ++ const label = t(pages.find(([key]) => key === shown)[1]) + const back = bot ? t('Back to Bots') : room ? t('Back to group chats') : usageFocus ? t('All usage') : null + const nav = useGlide(shown, { axis: 'y' }) + // A page enters from the right when it is one level in, from the left when it comes +@@ -181,12 +181,12 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = const errorLine = error ? h('div', { className: 'bt-error', role: 'alert', style: { padding: 0 } }, error) : null return h('div', { className: 'bt-settings-layer', 'data-leaving': leaving || undefined }, h('div', { className: 'bt-scrim', onMouseDown: close }), @@ -201,7 +225,22 @@ diff --git a/src/client/settings.js b/src/client/settings.js + h('div', { className: 'bt-settings', role: 'dialog', 'aria-modal': true, 'aria-label': t('FlyBot settings'), 'data-wide': shown === 'usage' ? '' : undefined }, h('nav', { ref: nav.box, className: 'bt-settings-nav', 'aria-label': t('Settings pages') }, h('span', { ref: nav.thumb, className: 'bt-thumb', 'aria-hidden': true }), - SETTINGS_PAGES.map(([key, text]) => h('button', { +- SETTINGS_PAGES.map(([key, text]) => h('button', { ++ pages.map(([key, text]) => h('button', { + key, type: 'button', className: 'bt-settings-item', 'aria-current': shown === key ? 'page' : undefined, onClick: () => actions.openSettings(key), +- }, h(PAGE_ICONS[key]), t(text))), ++ }, h(extra.find(item => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text))), + h('button', { + type: 'button', className: 'bt-settings-item bt-settings-shell', title: t('Models, providers, and the rest of DeepSeek Harness'), + onClick: () => { close(); openHarnessSettings() }, +@@ -203,6 +203,7 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = + room ? h('div', { className: 'bt-settings-form' }, h(GroupSettingsForm, { key: room.id, room, roster, actions, run })) : null, + shown === 'groups' && !room ? h(GroupsPage, { roster, actions, run }) : null, + shown === 'connectors' ? h(ConnectorsPage, { roster, actions }) : null, ++ pluginPage ? h(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call, run, t, Section: SetSection, Row: SetRow } }) : null, + shown === 'capabilities' ? h(CapabilitiesPage, { roster, actions, run }) : null, + shown === 'schedules' ? h(SchedulesPage, { roster, actions, start: id ?? undefined }) : null, + shown === 'secrets' ? h(SecretsPage, { roster, actions, run, Section: SetSection }) : null, diff --git a/src/host/browser.js b/src/host/browser.js --- a/src/host/browser.js +++ b/src/host/browser.js diff --git a/patches/ds-bot/files/src/client/flybot-registry.js b/patches/ds-bot/files/src/client/flybot-registry.js index 3e5174a35..7f1f110f6 100644 --- a/patches/ds-bot/files/src/client/flybot-registry.js +++ b/patches/ds-bot/files/src/client/flybot-registry.js @@ -1,6 +1,6 @@ // The FlyBot slot registry, free of React so the "@" menu and tests can use it. // See flybot-slots.js for what each slot carries. -export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header'] +export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page'] export function createRegistry() { const entries = new Map(SLOTS.map(name => [name, []])) diff --git a/patches/ds-bot/files/src/client/flybot-slots.js b/patches/ds-bot/files/src/client/flybot-slots.js index bfb32938b..bce0f8586 100644 --- a/patches/ds-bot/files/src/client/flybot-slots.js +++ b/patches/ds-bot/files/src/client/flybot-slots.js @@ -17,6 +17,9 @@ // approval ids it shows: the overlay then draws no second card for them (#260) // flybot.new-chat.action { id, order, label, hint?, visible?, run({ close, actions, roster }) } a row in the sidebar '+' menu (next to Create group chat; Face-to-face group, Add friend, Friend requests) // flybot.account.header { id, order, component({ actions, call }) } top of the account menu (the owner's own Bot Social handle) +// flybot.settings.page { id, order, label, icon?, component({ roster, actions, call, run, t, Section, Row }) } a page in the Settings list +// (after Connectors); `label` is English and goes through the host's t(); the component gets the host's t(), +// Section(title) and Row(label, hint) so the page looks like the others and reads zh in a zh UI // // The same registry object also carries the host Bot look so a plugin can draw a Bot // exactly as the sidebar does: __flybot__.BotAvatar / BotMark / lookOf (set in registry()). @@ -73,6 +76,16 @@ function Safe({ entry, props }) { return h(SlotBoundary, { id: entry.id }, h(entry.component, props)) } +// Pages plugins add to Settings (flybot.settings.page), in order. +export function useSettingsPages() { + return useSlot('flybot.settings.page') +} + +// Renders a plugin's Settings page without letting its failure close the dialog. +export function SettingsPageSlot({ entry, props }) { + return h(Safe, { entry, props }) +} + // The first 'flybot.sidebar.structure' entry, or undefined: the sidebar then lists Bots flat. export function useSidebarStructure() { return useSlot('flybot.sidebar.structure')[0] diff --git a/patches/ds-bot/files/test/flybot-client.test.js b/patches/ds-bot/files/test/flybot-client.test.js index 566a1f5b9..464384311 100644 --- a/patches/ds-bot/files/test/flybot-client.test.js +++ b/patches/ds-bot/files/test/flybot-client.test.js @@ -15,7 +15,7 @@ test('the client registry orders entries, replaces by id, and notifies', () => { // The replaced entry stays: disposing the old one does not remove its successor. assert.deepEqual(slots.entries('flybot.sidebar.section').map(e => e.id), ['b', 'a']) assert.throws(() => slots.register('nope', { id: 'x' }), /unknown slot/) - assert.deepEqual(SLOTS, ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header']) + assert.deepEqual(SLOTS, ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page']) }) test('new-chat action rows sit next to Create group chat and skip a failing plugin', () => { diff --git a/patches/ds-bot/files/test/settings-page-slot.test.js b/patches/ds-bot/files/test/settings-page-slot.test.js new file mode 100644 index 000000000..36c617700 --- /dev/null +++ b/patches/ds-bot/files/test/settings-page-slot.test.js @@ -0,0 +1,61 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// #337: a plugin adds a page to Settings (flybot.settings.page), after Connectors. +const { outputFiles } = await build({ + stdin: { + contents: "export { SettingsDialog } from './src/client/settings.js'; export { registry } from './src/client/flybot-registry.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 } }) +const react = { createElement: h, Component: class {}, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {}, useState: v => [typeof v === 'function' ? v() : v, () => {}], useMemo: f => f(), useRef: current => ({ current }), useCallback: f => f, useSyncExternalStore: (_s, get) => get() } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, document: { hidden: false, addEventListener() {}, removeEventListener() {} }, navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, + window: { addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) }, + 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}`) + }, + console, setTimeout, clearTimeout, requestAnimationFrame: () => 0, cancelAnimationFrame() {}, +}) +const { SettingsDialog, registry } = module.exports +const all = (node, out = []) => { if (node && typeof node === 'object') { out.push(node); for (const c of [node.props?.children].flat(Infinity)) all(c, out) } return out } +const text = node => (typeof node === 'string' ? node : [node?.props?.children].flat(Infinity).map(text).join('')) +const roster = { bots: [], rooms: [], byId: {}, roomsById: {}, prefs: {}, mainBotIds: [], connectors: [] } +const actions = { openSettings() {}, closeSettings() {} } + +test('a flybot.settings.page entry is listed after Connectors and drawn with the host t, Section, Row and call', () => { + const off = registry().register('flybot.settings.page', { id: 'social', order: 100, label: 'Bot Social', component: () => null }) + try { + const tree = SettingsDialog({ page: 'plugin:social', id: null, roster, actions, useUpdate: () => ({}) }) + const nodes = all(tree) + const items = nodes.filter(node => node.type === 'button' && node.props.className === 'bt-settings-item').map(text) + assert.deepEqual(items.slice(3, 6), ['Connectors', 'Bot Social', 'Capabilities']) + assert.equal(text(nodes.find(node => node.type === 'h2')), 'Bot Social') + const slot = nodes.find(node => node.props?.entry?.id === 'social') + assert.ok(slot) + assert.deepEqual(Object.keys(slot.props.props).sort(), ['Row', 'Section', 'actions', 'call', 'roster', 'run', 't'].sort()) + assert.equal(slot.props.props.t('Bot Social'), 'Bot Social') + assert.equal(typeof slot.props.props.call, 'function') + } finally { + off() + } +}) + +test('a page whose plugin is gone falls back to General, and without plugins the list is unchanged', () => { + const tree = SettingsDialog({ page: 'plugin:social', id: null, roster, actions, useUpdate: () => ({}) }) + const nodes = all(tree) + assert.equal(text(nodes.find(node => node.type === 'h2')), 'General') + const items = nodes.filter(node => node.type === 'button' && node.props.className === 'bt-settings-item').map(text) + assert.deepEqual(items.slice(0, 6), ['General', 'Bots', 'Group chats', 'Connectors', 'Capabilities', 'Scheduled tasks']) + assert.equal(nodes.some(node => node.props?.entry !== undefined), false) +}) diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index de5f6ca4c..ae18d726c 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -464,6 +464,9 @@ export type FlyBotCall = (endpoint: string, payload?: unknown) => Promise * Social replies waiting for approval). * - `flybot.account.header`: a compact block at the top of the account menu (the social * plugin shows the owner's own Bot Social handle with a copy button). + * - `flybot.settings.page`: a page in the Settings list, after Connectors. `label` is an English + * key looked up with the host `t()`; the component receives the host `t`, `Section(title)` and + * `Row(label, hint)` so it matches the other pages and reads in the DSH language. * - `flybot.new-chat.action`: a row in the sidebar '+' new-chat menu, next to * Create group chat (e.g. Face-to-face group, Add friend). `label` / `hint` are English keys * looked up with the same `t()` map as the neighbouring rows. @@ -484,6 +487,14 @@ export interface FlyBotSlots { 'flybot.room.overview': { id: string; order?: number; component(props: { room: any; roster: any; actions: any; call: FlyBotCall }): any } 'flybot.session.dock': { id: string; order?: number; /** The `by` of the approvals this dock draws; the overlay then draws no second card for the ids it claims. */ policy?: string; component(props: { sessionId?: string; roster?: any; room?: any; bot?: any; actions?: any; call: FlyBotCall; /** Report the approval ids this dock shows (a plugin's own policy). */ claim?: (ids: string[]) => void }): any } 'flybot.account.header': { id: string; order?: number; component(props: { actions?: any; call: FlyBotCall }): any } + 'flybot.settings.page': { + id: string + order?: number + label: string + /** A React component drawn beside the label in the Settings list (default: the connector mark). */ + icon?: any + component(props: { roster: any; actions: any; call: FlyBotCall; run(promise: Promise): Promise; t(key: string, params?: Record): string; Section: any; Row: any }): any + } 'flybot.new-chat.action': { id: string order?: number diff --git a/plugins/social/README.md b/plugins/social/README.md index c3fba463c..5899a61b6 100644 --- a/plugins/social/README.md +++ b/plugins/social/README.md @@ -3,6 +3,12 @@ Every local Bot can chat with friends' Bots on a [Bot Social](https://github.com/Birfy/bot-social) server. The plugin talks to a Bot Social server that you self-host; see the Bot Social README for install and configuration. Host: `index.js`; browser: `client.js`. Design: see "Social design: homes and members" in the root README. +- **Server in Settings** (#337): Settings → **Bot Social** shows the server, where it comes from and the + connection, and switches to another server without a restart (`https://`, or `http://localhost`; + `/v1/platform` is read first and its features shown). The choice is kept in + `flybot-social-server.json` beside the credentials; `config.server` stays the default. A server is an + identity: each has its own credentials file (the previous one is kept, never deleted), settings, and + per-Bot identities and grants, and switching back restores them. - **One-click onboarding**: on first start, `POST /v1/onboard/flybot` creates the owner, the home identity (`@.home`), and a connector token in one call; credentials go to `credentialsPath` (default `$DSH_HOME/bot/flybot-social.json`, mode 600). diff --git a/plugins/social/client.js b/plugins/social/client.js index cfc16cbde..d709a118d 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -136,6 +136,12 @@ window.__ModuleLoader__.load({ .fs-stack .fs-face-mark:first-child,.fs-stack [data-flybot=face-card-member]:first-child{margin-left:0} .fs-stack [data-flybot=face-card-member]{display:inline-flex;border-radius:50%;box-shadow:0 0 0 2px var(--bt-card,#fff)} .fs-stack-more{flex:none;font-size:11px;line-height:14px;color:var(--bt-ink-3,rgba(20,20,20,.36));margin-left:4px} +.fs-srv-form{display:flex;flex-direction:column;gap:8px;padding:10px 12px} +.fs-srv-quick{display:flex;flex-wrap:wrap;gap:4px 10px;align-items:baseline} +.fs-srv-checked{display:flex;flex-direction:column;gap:2px} +.fs-srv-warn{display:flex;flex-direction:column;gap:4px;padding:8px 10px;border-radius:8px;background:var(--bt-hover,rgba(119,119,119,.09));font-size:12px;line-height:17px;color:var(--bt-ink-2,rgba(20,20,20,.6))} +.fs-srv-warn strong{color:var(--bt-ink,#141414);font-size:13px} +.fs-srv-actions{display:flex;gap:6px;justify-content:flex-end} .fs-chip{flex:none;font-size:11px;line-height:14px;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg,rgba(20,20,20,.06));color:var(--bt-tag-ink,rgba(20,20,20,.56));white-space:nowrap} .fs-chip-status{min-width:44px;text-align:center;font-weight:600;letter-spacing:.01em} .fs-chip-status[data-status=open],.fs-chip-status[data-status=closed],.fs-chip-status[data-status=left]{font-size:12px;line-height:16px;padding:2px 8px;color:var(--bt-ink-2,rgba(20,20,20,.72));background:var(--bt-hover,rgba(119,119,119,.12))} @@ -1639,6 +1645,97 @@ window.__ModuleLoader__.load({ note ? h('div', { className: 'fs-status', 'data-tone': note.tone }, note.text) : null) } + const SocialPageIcon = () => h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', 'aria-hidden': true }, + h('circle', { cx: 8, cy: 8, r: 6 }), h('path', { d: 'M2 8h12M8 2c2 2 2 10 0 12M8 2c-2 2-2 10 0 12' })) + + // Settings → Bot Social (#337): the server this FlyBot talks to. `t` is the host's translator (zh in a zh UI), + // `Section` / `Row` the host's Settings layout, so the page looks like Connectors. + const STATE_TEXT = { + connected: (t, view) => (view.handle ? t('Connected as {handle}', { handle: view.handle }) : t('Connected')), + connecting: t => t('Connecting…'), + 'not-configured': t => t('No server set. Friends and sharing are off until you set one.'), + error: (t, view) => t('Could not connect: {error}', { error: view.error ?? '' }), + } + const serverHost = (address) => { try { return new URL(address).host } catch { return String(address ?? '') } } + function SocialServerSettings({ t: given, Section, Row } = {}) { + useStyles() + const t = given ?? ((text, params) => String(text).replace(/\{(\w+)\}/g, (_, key) => String(params?.[key] ?? ''))) + const [view, setView] = useState(null) + const [loadError, setLoadError] = useState(null) + const [live, setLive] = useState(null) + const [form, setForm] = useState(false) + const [address, setAddress] = useState('') + const [name, setName] = useState('') + const [checked, setChecked] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const refresh = async () => { + try { setView(await call('social/server', {})); setLoadError(null) } catch (e) { setLoadError(errorText(e)) } + } + useEffect(() => { void refresh() }, []) + // A server that is still connecting is read again until it settles. + useEffect(() => { + if (view?.state !== 'connecting' || typeof setInterval !== 'function') return undefined + const timer = setInterval(() => { void refresh() }, 2000) + return () => clearInterval(timer) + }, [view?.state]) + // What the current server lists, read once the page shows it. + useEffect(() => { + if (!view?.server || view.state === 'not-configured') return undefined + let off = false + call('social/server-check', {}).then(info => { if (!off) setLive({ ok: true, ...info }) }, e => { if (!off) setLive({ ok: false, error: errorText(e) }) }) + return () => { off = true } + }, [view?.server, view?.state === 'connected']) + const platform = live?.ok ? live : view?.platform + const open = (value = '') => { setForm(true); setAddress(value); setChecked(null); setError(null) } + const close = () => { setForm(false); setChecked(null); setError(null); setBusy(false) } + const check = async (value = address) => { + setBusy(true); setError(null); setChecked(null) + try { setChecked(await call('social/server-check', { server: value })); setAddress(value) } catch (e) { setError(errorText(e)) } finally { setBusy(false) } + } + const change = async () => { + setBusy(true); setError(null) + try { + setView(await call('social/server-set', { server: checked.server, displayName: name.trim() || undefined, confirm: true })) + setLive(null) + close() + } catch (e) { setError(errorText(e)); void refresh() } finally { setBusy(false) } + } + if (!view) return h('div', { className: 'fs-status', 'data-tone': loadError ? 'error' : undefined, 'data-flybot': 'social-server' }, loadError ?? t('Loading…')) + const source = view.source === 'settings' ? t('Set here') : view.source === 'config' ? t('Profile default') : t('Not set') + const status = (STATE_TEXT[view.state] ?? STATE_TEXT.connecting)(t, view) + const quick = [...(view.defaultServer && view.source === 'settings' ? [view.defaultServer] : []), ...view.known].filter((item, at, all) => all.indexOf(item) === at) + return h('div', { 'data-flybot': 'social-server' }, + h(Section, { title: t('Bot Social server') }, + h(Row, { label: view.server ?? t('No server'), hint: status }, + h('span', { className: 'fs-tag' }, source), + view.canChange && !form ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm', 'data-flybot': 'server-change', onClick: () => open() }, t('Change server')) : null), + platform ? h(Row, { label: t('Features'), hint: platform.features?.length ? platform.features.join(', ') : t('No optional features listed.') }, null) : null, + live && !live.ok ? h(Row, { label: t('Reachability'), hint: live.error }, null) : null, + !view.canChange ? h(Row, { label: t('Read-only'), hint: t('Another FlyBot window holds this team. Change the server there.') }, null) : null), + form ? h(Section, { title: t('Switch server') }, + h('div', { className: 'fs-srv-form' }, + h('input', { className: 'bt-input', value: address, spellCheck: false, autoFocus: true, 'aria-label': t('Server address'), placeholder: 'https://social.example', 'data-flybot': 'server-address', onChange: e => { setAddress(e.target.value); setChecked(null); setError(null) }, onKeyDown: e => { if (e.key === 'Enter' && address.trim() && !busy) void check() } }), + quick.length ? h('div', { className: 'fs-srv-quick' }, h('span', { className: 'bt-set-hint' }, t('Used before:')), quick.map(item => h('button', { key: item, type: 'button', className: 'fs-link', onClick: () => { setAddress(item); void check(item) } }, item === view.defaultServer ? t('{host} (profile default)', { host: serverHost(item) }) : serverHost(item)))) : null, + h('input', { className: 'bt-input', value: name, spellCheck: false, 'aria-label': t('Name for your home (optional)'), placeholder: t('Name for your home (optional)'), maxLength: 40, onChange: e => setName(e.target.value) }), + checked ? h('div', { className: 'fs-srv-checked', 'data-flybot': 'server-checked' }, + h('div', { className: 'fs-status' }, `${checked.name}${checked.version ? ` ${checked.version}` : ''} · ${serverHost(checked.server)}`), + h('div', { className: 'fs-status' }, checked.features?.length ? `${t('Features')}: ${checked.features.join(', ')}` : t('No optional features listed.'))) : null, + checked && !checked.same ? h('div', { className: 'fs-srv-warn', role: 'alert', 'data-flybot': 'server-warning' }, + h('strong', null, t('Switching servers changes who you are')), + h('span', null, checked.known ? t('You used this server before. Your saved identity, friends and settings for it come back.') : t('You get a new identity on this server. The friends and sharing you have now stay on the current server; they do not move.')), + h('span', null, t('Your current sign-in is kept as a backup on this computer and is never deleted. Switch back any time.')), + h('span', null, t('Your Bots stop being shared with friends until you share them again on the new server.'))) : null, + checked && checked.same ? h('div', { className: 'fs-status' }, t('This is the server you already use.')) : null, + error ? h('div', { className: 'bt-connector-error', role: 'alert' }, error) : null, + h('div', { className: 'fs-srv-actions' }, + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: close }, t('Cancel')), + checked && !checked.same + ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, 'data-flybot': 'server-confirm', onClick: () => void change() }, busy ? t('Switching…') : t('Switch to this server')) + : h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy || address.trim() === '', 'data-flybot': 'server-check', onClick: () => void check() }, busy ? t('Checking…') : t('Check'))))) + : null) + } + let cache = { at: 0, list: [] } const friendRows = async (query) => { if (Date.now() - cache.at > 30_000) cache = { at: Date.now(), list: await call('social/friends', {}) } @@ -1657,6 +1754,7 @@ window.__ModuleLoader__.load({ InviteToBot, ManageAccess, BotSocialDetails, + SocialServerSettings, AddFriend, PendingApprovals, ChatApproval, @@ -1707,6 +1805,7 @@ window.__ModuleLoader__.load({ slots().register('flybot.composer.mention', { id: 'social', order: 100, candidates: friendRows }), (() => { try { return slots().register('flybot.account.header', { id: 'social-handle', order: 10, component: MyHandle }) } catch { return () => {} } })(), slots().register('flybot.bot.details', { id: 'social-invite', order: 100, component: BotSocialDetails }), + slots().register('flybot.settings.page', { id: 'social', order: 100, label: 'Bot Social', icon: SocialPageIcon, component: SocialServerSettings }), slots().register('flybot.room.overview', { id: 'social-face', order: 50, component: FaceRoomOverview }), slots().register('flybot.session.dock', { id: 'social-room-end', order: 40, component: FaceRoomStatus }), slots().register('flybot.session.dock', { id: 'social-approval', order: 50, policy: 'social', component: ChatApprovalDock }), diff --git a/plugins/social/cordis.patch.yml b/plugins/social/cordis.patch.yml index 42afa4fdc..2ab38b824 100644 --- a/plugins/social/cordis.patch.yml +++ b/plugins/social/cordis.patch.yml @@ -2,7 +2,8 @@ # Set the Bot Social server in your profile patch, for example: # - id: flybot-social # config: { server: https://social.example, displayName: Me } -# Without a server it does not onboard and stays idle. +# Without a server it does not onboard and stays idle. Settings → Bot Social can pick another server +# later (kept beside the credentials); the value here stays the default. - insert: - id: flybot-social name: '@flybot/social' diff --git a/plugins/social/index.js b/plugins/social/index.js index f674e730a..7bcbae7f7 100644 --- a/plugins/social/index.js +++ b/plugins/social/index.js @@ -3,7 +3,8 @@ // FlyBot API (ctx.flybot). See README.md for the home / member model. // // Config: -// server Bot Social base URL +// server Bot Social base URL: the default. Settings → Bot Social can pick another one +// (kept in flybot-social-server.json beside the credentials, #337) // credentialsPath where the one-call onboarding keeps { token, home } (default // $DSH_HOME/bot/flybot-social.json, mode 600; never committed) // displayName name for the home on first onboarding @@ -14,34 +15,43 @@ // presence false turns off "Name is thinking…" for friends' Bots (Bot Social presence signal, #297; default on) // token / botId legacy per-bot identity (no home): owner connector token + bot id import { join } from 'node:path' -import { registerUnavailable, startFailureReason, startSocial } from './src/social.js' +import { NS, registerUnavailable, startFailureReason, startSocial } from './src/social.js' +import { createServerController } from './src/server-config.js' export const name = 'flybot-social' export const inject = ['flybot'] export function apply(ctx, config = {}) { - let social - let stopped = false - let unavailableOff const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? '.', '.dsh') - const ready = startSocial(ctx.flybot, { inboxPollMs: 15_000, facePollMs: 4_000, credentialsPath: join(dshHome, 'bot', 'flybot-social.json'), ...config }) - .then((value) => { - social = value - if (stopped) social.dispose() - return value - }) - .catch((error) => { + const base = { inboxPollMs: 15_000, facePollMs: 4_000, credentialsPath: join(dshHome, 'bot', 'flybot-social.json'), ...config } + const flybot = typeof ctx.flybot.scope === 'function' ? ctx.flybot.scope(NS) : ctx.flybot + const warn = (...args) => console.warn('flybot-social:', ...args) + // The server comes from Settings when the owner picked one there, else from `config.server` + // (#337); the controller starts the social layer and restarts it when the server changes. + const controller = createServerController({ + base, + flybot, + start: settled => startSocial(ctx.flybot, settled), + registerUnavailable, + warn, + onFailure(error) { const reason = startFailureReason(error) console.warn('flybot-social: start failed:', reason) try { ctx.logger?.warn?.('flybot-social: start failed: %s', reason) } catch { /* the logger failed while reporting the failure */ } // Host diagnostics (Settings → Feedback). Optional on older cores without flybot.warn. try { ctx.flybot?.warn?.('flybot-social: start failed: %s', error) } catch { /* the optional channel itself failed */ } - if (!stopped) unavailableOff = registerUnavailable(ctx.flybot, reason) - }) + }, + }) + const offs = [ + flybot.api.handle('social/server', async () => controller.status(), { read: true }), + // A check makes the host fetch an address the caller names, so it is a write for the access rules (no read-only host, no team of several). + flybot.api.handle('social/server-check', async payload => controller.check(payload ?? {})), + flybot.api.handle('social/server-set', async payload => controller.set(payload ?? {})), + ] + const ready = controller.ready() ctx.effect(() => () => { - stopped = true - unavailableOff?.() - social?.dispose() + for (const off of offs.splice(0).reverse()) off() + controller.dispose() }, 'flybot-social') return ready } diff --git a/plugins/social/src/server-config.js b/plugins/social/src/server-config.js new file mode 100644 index 000000000..517ac04a2 --- /dev/null +++ b/plugins/social/src/server-config.js @@ -0,0 +1,278 @@ +// Which Bot Social server this FlyBot talks to, changeable from Settings (#337). +// +// The profile patch (`flybot-social` config.server) stays the default and the fallback. A server the +// owner picks in Settings is kept in `flybot-social-server.json` next to the credentials file and wins +// while it exists. A server is an identity: every server has its own credentials file (the default +// server's stays where it always was; another server's gets the `.` suffix startSocial already +// used for backups), its own settings file, and its own member identities, grants and Auto-send marks +// on the local Bots (parked in `flybot-social-bots..json` while another server is active). +// Nothing here deletes a credentials file, and nothing here reads, returns or logs a token. +import { createHash } from 'node:crypto' +import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { socialFetch } from './mcp-client.js' + +export const serverKey = url => String(url ?? '').trim().replace(/\/+$/, '').toLowerCase() +export const serverTag = key => createHash('sha256').update(serverKey(key)).digest('hex').slice(0, 8) + +const refused = (message, code = 'bad_server') => Object.assign(new Error(message), { code: 'bot/refused', reason: code }) +const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']) +const MAX_URL = 200 +const PROBE_MS = 8_000 +const START_MS = 45_000 + +// "https://social.example", or "http://localhost:8787" for development. Returns the normalized address +// (no trailing slash) or throws a refusal that says what to change. +export function parseServerUrl(input) { + const text = String(input ?? '').trim() + if (text === '') throw refused('Enter the Bot Social server address, for example https://social.example.') + if (text.length > MAX_URL) throw refused('That address is too long.') + const loopbackHost = /^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(text) + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `${loopbackHost ? 'http' : 'https'}://${text}` + let url + try { url = new URL(withScheme) } catch { throw refused('That is not a valid web address.') } + if (url.protocol !== 'https:' && url.protocol !== 'http:') throw refused('The address must start with https://.') + if (url.protocol === 'http:' && !LOOPBACK.has(url.hostname)) { + throw refused('The address must start with https://. Plain http:// is only accepted for localhost, for development.') + } + if (url.username !== '' || url.password !== '') throw refused('Leave the user name and password out of the address.') + if (url.search !== '' || url.hash !== '') throw refused('Use the plain server address, without ? or # parts.') + if (url.hostname === '') throw refused('That is not a valid web address.') + return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, '')}` +} + +const cleanLine = (value, max) => String(value ?? '').replace(/[\u0000-\u001f\u007f<>]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max) + +// GET /v1/platform: what the server is and which optional features it lists. A redirect is a +// refusal (the address saved is the address checked), and so is anything that does not look like Bot Social. +export async function probeServer(server, { fetch: fetchImpl = socialFetch, timeoutMs = PROBE_MS } = {}) { + const host = (() => { try { return new URL(server).host } catch { return 'that server' } })() + let response + try { + response = await fetchImpl(`${server}/v1/platform`, { method: 'GET', headers: { accept: 'application/json' }, redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) }) + } catch (error) { + const timedOut = error?.name === 'TimeoutError' || error?.name === 'AbortError' + const why = timedOut ? 'it did not answer in time' : cleanLine(error?.cause?.code ?? error?.code ?? 'the connection failed', 60) + throw refused(`Could not reach ${host}: ${why}.`, 'unreachable') + } + if (response.status >= 300 && response.status < 400) throw refused(`${host} redirects somewhere else. Enter the address it redirects to.`, 'redirect') + if (!response.ok) throw refused(`${host} answered HTTP ${response.status}. Is this a Bot Social server?`, 'not_social') + let info + try { info = JSON.parse((await response.text()).slice(0, 262_144)) } catch { info = undefined } + const isObject = info !== null && typeof info === 'object' && !Array.isArray(info) + if (!isObject || (typeof info.name !== 'string' && info.platform_key === undefined && !Array.isArray(info.features))) { + throw refused(`${host} did not answer like a Bot Social server.`, 'not_social') + } + const features = (Array.isArray(info.features) ? info.features : []).map(item => cleanLine(item, 40)).filter(Boolean).slice(0, 40) + return { name: cleanLine(info.name, 60) || 'Bot Social', version: cleanLine(info.version, 30) || undefined, features } +} + +const writePrivate = (path, value) => { + mkdirSync(dirname(path), { recursive: true }) + const temp = `${path}.${process.pid}.tmp` + writeFileSync(temp, JSON.stringify(value, null, 2), { mode: 0o600 }) + renameSync(temp, path) + try { chmodSync(path, 0o600) } catch { /* file systems that ignore the write mode */ } +} +const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return undefined } } + +/** + * @param base the plugin config (yaml) with the plugin defaults; `server` is the default server + * @param flybot the plugin-scoped FlyBot service + * @param start (config) => Promise: startSocial + */ +export function createServerController({ base, flybot, start, registerUnavailable, onFailure, probe = probeServer, warn = () => {}, startTimeoutMs = START_MS }) { + const credentialsPath = base.credentialsPath + const dir = dirname(credentialsPath) + const overridePath = join(dir, 'flybot-social-server.json') + const defaultSettings = base.settingsPath ?? join(dir, 'flybot-social-settings.json') + const defaultKey = serverKey(base.server) + const botsPath = tag => join(dir, `flybot-social-bots.${tag}.json`) + + const readOverride = () => { + const saved = readJson(overridePath) + if (typeof saved?.server !== 'string') return undefined + try { return { server: parseServerUrl(saved.server), displayName: cleanLine(saved.displayName, 40) || undefined } } catch { return undefined } + } + const effective = () => { + const override = readOverride() + if (override) return { ...override, source: 'settings' } + return { server: base.server ? String(base.server) : undefined, displayName: undefined, source: base.server ? 'config' : 'none' } + } + // The default server keeps its files; any other server gets its own beside them. + const configFor = ({ server, displayName }) => { + if (!server) return { ...base } + const key = serverKey(server) + const name = displayName ?? base.displayName + if (key === defaultKey) return { ...base, server, ...(name ? { displayName: name } : {}) } + const tag = serverTag(key) + return { + ...base, + server, + displayName: name ?? 'FlyBot', + credentialsPath: `${credentialsPath}.${tag}`, + settingsPath: defaultSettings.replace(/(\.json)?$/, `.${tag}$1`), + } + } + + let current = effective() + let social + let unavailableOff + let state = current.server ? 'connecting' : 'not-configured' + let error + let generation = 0 + let busy = false + let lastProbe + + const stop = () => { + generation += 1 + unavailableOff?.() + unavailableOff = undefined + try { social?.dispose() } catch (failure) { warn('flybot-social: stop failed:', failure) } + social = undefined + } + + // Starts the social layer for a server and settles with it; a start that finishes after it was + // replaced (or timed out) is disposed instead of kept. + function launch(target) { + const mine = ++generation + state = target.server ? 'connecting' : 'not-configured' + error = undefined + const config = configFor(target) + const run = start(config) + let timeout + const timer = new Promise((_, reject) => { timeout = setTimeout(() => reject(refused('the server did not finish connecting in time.', 'timeout')), startTimeoutMs); timeout.unref?.() }) + return Promise.race([run, timer]).finally(() => clearTimeout(timeout)).then((value) => { + if (mine !== generation) { value.dispose(); throw refused('the connection was replaced.', 'replaced') } + social = value + state = target.server ? 'connected' : 'not-configured' + return value + }, (failure) => { + void run.then(late => { if (mine !== generation || social !== late) late.dispose() }, () => { /* the start failed: nothing is running */ }) + if (mine === generation) { + // Without a server the start fails as it always did ("server URL is required"); the page says "not set", not an error. + const reason = cleanLine(failure?.message ?? failure, 240) || 'the plugin did not start' + state = target.server ? 'error' : 'not-configured' + error = target.server ? reason : undefined + unavailableOff = registerUnavailable?.(flybot, reason) + onFailure?.(failure) + } + throw failure + }) + } + + // ---- the identities of the local Bots belong to the server that issued them + function park(key) { + const bots = {} + for (const bot of flybot.bot.list()) { + const entry = {} + const member = flybot.fields.get(bot.id, 'member') + const auto = flybot.fields.get(bot.id, 'autoApprove') + const grants = flybot.grants?.of?.(bot.id) ?? [] + if (member) entry.member = member + if (auto) entry.autoApprove = auto + if (grants.length > 0) entry.grants = grants + if (Object.keys(entry).length > 0) bots[bot.id] = entry + } + if (Object.keys(bots).length === 0) return Promise.resolve() + // Saved before anything is cleared: a failed write stops the switch with the Bots untouched. + writePrivate(botsPath(serverTag(key)), { server: serverKey(key), savedAt: new Date().toISOString(), bots }) + return Promise.all(Object.keys(bots).map(async (id) => { + flybot.fields.set(id, 'member', undefined) + flybot.fields.set(id, 'autoApprove', undefined) + try { await flybot.grants?.set?.(id, []) } catch (failure) { warn('flybot-social: grants not cleared:', failure) } + })) + } + async function restore(key) { + const saved = readJson(botsPath(serverTag(key))) + if (!saved || serverKey(saved.server) !== serverKey(key)) return + for (const [id, entry] of Object.entries(saved.bots ?? {})) { + if (!flybot.bot.get(id)) continue + try { + if (entry.member && !flybot.fields.get(id, 'member')) flybot.fields.set(id, 'member', entry.member) + if (entry.autoApprove && !flybot.fields.get(id, 'autoApprove')) flybot.fields.set(id, 'autoApprove', entry.autoApprove) + if (Array.isArray(entry.grants) && entry.grants.length > 0) await flybot.grants?.set?.(id, entry.grants) + } catch (failure) { warn('flybot-social: saved Bot identity not restored:', failure) } + } + } + const writeOverride = (target) => { + if (target.server && serverKey(target.server) !== defaultKey) writePrivate(overridePath, { server: target.server, ...(target.displayName ? { displayName: target.displayName } : {}), savedAt: new Date().toISOString() }) + else rmSync(overridePath, { force: true }) + } + + // Servers this profile has saved credentials for (the address only, never the credentials). + function knownServers() { + const found = new Set() + const add = path => { const saved = readJson(path); if (typeof saved?.server === 'string' && saved.token) found.add(saved.server) } + add(credentialsPath) + try { + const stem = `${basename(credentialsPath)}.` + for (const name of readdirSync(dir)) if (name.startsWith(stem) && /^[0-9a-f]{8}$/.test(name.slice(stem.length))) add(join(dir, name)) + } catch { /* no data folder yet */ } + if (current.server) found.delete(serverKey(current.server)) + return [...found].filter(item => serverKey(item) !== serverKey(current.server)).slice(0, 6) + } + + const view = () => ({ + server: current.server ?? null, + source: current.source, + defaultServer: base.server ? String(base.server) : null, + displayName: current.displayName ?? (base.displayName ? String(base.displayName) : null), + state, + handle: social?.home?.handle ?? null, + error: error ?? null, + canChange: flybot.readOnly?.() !== true, + known: knownServers(), + ...(lastProbe && serverKey(lastProbe.server) === serverKey(current.server) ? { platform: lastProbe.info } : {}), + }) + + return { + status: view, + ready: () => launch(current).catch(() => undefined), + async check({ server } = {}) { + const address = server === undefined || server === '' ? current.server : parseServerUrl(server) + if (!address) throw refused('No server is set yet.', 'no_server') + const info = await probe(address, { fetch: base.fetch }) + if (serverKey(address) === serverKey(current.server)) lastProbe = { server: address, info } + return { server: address, ...info, same: serverKey(address) === serverKey(current.server), known: knownServers().some(item => serverKey(item) === serverKey(address)) || serverKey(address) === defaultKey && Boolean(readJson(credentialsPath)?.token) } + }, + async set({ server, displayName, confirm } = {}) { + if (flybot.readOnly?.() === true) throw refused('Another FlyBot window holds this team. Change the server there.', 'read_only') + const address = parseServerUrl(server) + const name = cleanLine(displayName, 40) || undefined + if (serverKey(address) === serverKey(current.server) && !name) return view() + if (confirm !== true) throw refused('Confirm that you want to switch servers.', 'confirm_required') + if (busy) throw refused('A server change is already running.', 'busy') + busy = true + try { + const info = await probe(address, { fetch: base.fetch }) + const previous = current + const target = { server: address, displayName: name, source: serverKey(address) === defaultKey ? 'config' : 'settings' } + stop() + if (previous.server) await park(previous.server) + writeOverride(target) + await restore(address) + current = target + lastProbe = { server: address, info } + try { + await launch(target) + } catch (failure) { + // Back to the server that worked: its identities, its files, its override. + stop() + if (target.server) await park(target.server).catch(() => undefined) + writeOverride(previous) + if (previous.server) await restore(previous.server) + current = previous + lastProbe = undefined + await launch(previous).catch(() => undefined) + const why = cleanLine(failure?.message ?? failure, 200) || 'the server did not accept this FlyBot' + throw refused(`Could not connect to the new server: ${why} The previous server is back${state === 'connected' ? '' : ', but it did not reconnect either'}.`, 'switch_failed') + } + return view() + } finally { + busy = false + } + }, + dispose: stop, + } +} diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index cb6befcaf..9c6110e7a 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -518,7 +518,7 @@ test('client.js registers a sidebar section and @ rows into the FlyBot registry' runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) const plugin = registered[0].factory(() => ({ createElement: () => null, useEffect() {}, useState: v => [v, () => {}] })) plugin.apply({}) - assert.deepEqual(entries.map(([slot, entry]) => `${slot}#${entry.id}`), ['flybot.sidebar.section#social', 'flybot.new-chat.action#social-face-group', 'flybot.new-chat.action#social-add-friend', 'flybot.composer.mention#social', 'flybot.account.header#social-handle', 'flybot.bot.details#social-invite', 'flybot.room.overview#social-face', 'flybot.session.dock#social-room-end', 'flybot.session.dock#social-approval', 'flybot.session.dock#social-thinking']) + assert.deepEqual(entries.map(([slot, entry]) => `${slot}#${entry.id}`), ['flybot.sidebar.section#social', 'flybot.new-chat.action#social-face-group', 'flybot.new-chat.action#social-add-friend', 'flybot.composer.mention#social', 'flybot.account.header#social-handle', 'flybot.bot.details#social-invite', 'flybot.settings.page#social', 'flybot.room.overview#social-face', 'flybot.session.dock#social-room-end', 'flybot.session.dock#social-approval', 'flybot.session.dock#social-thinking']) // Transport errors are worded in English from their code, never the localized host text. assert.match(plugin.errorText(Object.assign(new Error('请求失败(HTTP 404)'), { code: 'HTTP_404' })), /^The FlyBot host did not answer \(HTTP 404\)/) assert.equal(plugin.errorText(Object.assign(new Error('x'), { code: 'bot/refused' })), 'x') @@ -837,7 +837,7 @@ test('invite card: "Copy link" copies the bare URL; "Copy with instructions" the // A client.js component tree with a scripted useState order. function clientPlugin(initial = [], { call = async () => ({}) } = {}) { const registered = [] - const sandbox = { window: { __ModuleLoader__: { load: r => registered.push(r) } }, setTimeout, clearTimeout, console, navigator: {} } + const sandbox = { window: { __ModuleLoader__: { load: r => registered.push(r) } }, setTimeout, clearTimeout, console, navigator: {}, URL } sandbox.globalThis = sandbox sandbox.__flybot__ = { slots: { register: () => () => {} }, call } runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) @@ -5625,3 +5625,347 @@ test('#310: Copy invite link always carries the /i/ segment', () => { assert.equal(inviteLink({ code: 'ABC' }), undefined) assert.equal(inviteLink({ url: 'not a url' }), 'not a url') }) + +// ---------------------------------------------------------------- the server in Settings (#337) +import { createServerController, parseServerUrl, probeServer, serverKey, serverTag } from '../src/server-config.js' + +// Several Bot Social servers behind one fetch, told apart by origin. `platform: false` makes one answer 404. +function fleet(spec) { + const servers = Object.fromEntries(Object.entries(spec).map(([origin, options]) => [origin, { ...options, mock: options.onboardFails ? undefined : mockServer({ token: options.token }) }])) + const seen = [] + const fetch = async (url, init = {}) => { + const text = String(url) + const origin = Object.keys(servers).find(item => text === item || text.startsWith(`${item}/`)) + seen.push({ url: text, redirect: init.redirect }) + if (!origin) throw Object.assign(new Error('fetch failed'), { cause: { code: 'ENOTFOUND' } }) + const server = servers[origin] + if (text.endsWith('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/v1/platform')) { + if (server.platform === false) return new Response('{"error":"nope"}', { status: 404 }) + if (server.redirect) return new Response('', { status: 302, headers: { location: 'https://elsewhere.test/' } }) + return new Response(JSON.stringify({ name: 'Bot Social', version: '0.2.0', features: ['presence', 'mentions'] }), { status: 200 }) + } + if (server.onboardFails && text.endsWith('/v1/onboard/flybot')) return new Response(JSON.stringify({ message: 'registration is closed' }), { status: 403 }) + return server.mock.fetch(url, init) + } + return { fetch, servers, seen, mock: origin => servers[origin].mock } +} + +const serverFiles = home => readdirSync(home).filter(file => file.startsWith('flybot-social')) + +test('parseServerUrl: https only, http for localhost, no credentials or query, normalized', () => { + assert.equal(parseServerUrl('https://Social.Example/'), 'https://social.example') + assert.equal(parseServerUrl('social.example'), 'https://social.example') + assert.equal(parseServerUrl(' https://social.example:8443/bs/ '), 'https://social.example:8443/bs') + assert.equal(parseServerUrl('http://localhost:8787'), 'http://localhost:8787') + assert.equal(parseServerUrl('localhost:8787'), 'http://localhost:8787') + assert.equal(parseServerUrl('http://127.0.0.1:3000/'), 'http://127.0.0.1:3000') + assert.equal(parseServerUrl('http://[::1]:3000'), 'http://[::1]:3000') + for (const bad of ['', ' ', 'http://social.example', 'http://localhost.evil.example', 'ftp://social.example', 'https://user:pw@social.example', 'https://social.example/?a=1', 'https://social.example/#x', 'https://', 'not a url', `https://${'a'.repeat(250)}.example`]) { + assert.throws(() => parseServerUrl(bad), (error) => error.code === 'bot/refused' && error.message.length > 0, bad) + } + assert.equal(serverKey('https://A.test/'), 'https://a.test') + assert.match(serverTag('https://A.test/'), /^[0-9a-f]{8}$/) +}) + +test('probeServer reads /v1/platform without following redirects and refuses what is not Bot Social', async () => { + const seen = [] + const answer = (status, body) => async (url, init) => { seen.push({ url, init }); return new Response(typeof body === 'string' ? body : JSON.stringify(body), { status }) } + const info = await probeServer('https://s.test', { fetch: answer(200, { name: 'Bot Social', version: '1', features: ['presence', 'x\n', 7] }) }) + assert.deepEqual(info, { name: 'Bot Social', version: '1', features: ['presence', 'b x /b', '7'] }) + assert.equal(seen[0].url, 'https://s.test/v1/platform') + assert.equal(seen[0].init.redirect, 'manual') + assert.equal(seen[0].init.headers.authorization, undefined) + // The platform document of a server without a feature list is still Bot Social. + assert.deepEqual(await probeServer('https://s.test', { fetch: answer(200, { name: 'Bot Social', platform_key: { alg: 'Ed25519' } }) }), { name: 'Bot Social', version: undefined, features: [] }) + await assert.rejects(probeServer('https://s.test', { fetch: answer(200, 'hello') }), /did not answer like a Bot Social server/) + await assert.rejects(probeServer('https://s.test', { fetch: answer(200, '[1]') }), /did not answer like a Bot Social server/) + await assert.rejects(probeServer('https://s.test', { fetch: answer(404, '{}') }), /HTTP 404/) + await assert.rejects(probeServer('https://s.test', { fetch: answer(302, '') }), /redirects somewhere else/) + await assert.rejects(probeServer('https://s.test', { fetch: async () => { throw Object.assign(new Error('x'), { cause: { code: 'ECONNREFUSED' } }) } }), /Could not reach s\.test: ECONNREFUSED/) + await assert.rejects(probeServer('https://s.test', { timeoutMs: 5, fetch: (url, init) => new Promise((_, reject) => init.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'TimeoutError' })))) }), /did not answer in time/) +}) + +test('Settings server: status, check and switch with the old credentials kept, then back without a second onboarding', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const servers = fleet({ 'https://a.test': { token: 'tok-a' }, 'https://b.test': { token: 'tok-b' } }) + const credentialsPath = join(home, 'flybot-social.json') + await applySocial(ctx, { server: 'https://a.test', credentialsPath, fetch: servers.fetch, inboxPollMs: 0, displayName: 'Danyang' }) + const flybot = ctx.flybot.scope('social') + const view = async () => (await call('flybot/social/server', {})).value + + let status = await view() + assert.equal(status.server, 'https://a.test') + assert.equal(status.source, 'config') + assert.equal(status.state, 'connected') + assert.equal(status.handle, '@danyang.home') + assert.equal(status.canChange, true) + assert.doesNotMatch(JSON.stringify(status), /tok-a/) + const credentialsA = readFileSync(credentialsPath, 'utf8') + + // A Bot with a friend on server A: its identity, grant and Auto-send mark belong to A. + await flybot.grants.set('a', [{ id: 'g1', grantee: '@bob.home', name: 'Bob' }]) + flybot.fields.set('a', 'autoApprove', { '@bob.home': true }) + await ctx.flybot.scope('social').fields.set('a', 'member', { bot_id: 'm1', handle: '@chief.danyang.home' }) + + // Check: nothing is saved, the platform's features are shown; refused addresses never reach the network. + const before = servers.seen.length + const bad = await call('flybot/social/server-check', { server: 'http://b.test' }) + assert.equal(bad.ok, false) + assert.match(bad.error.message, /https:\/\//) + assert.equal(servers.seen.length, before) + const checked = (await call('flybot/social/server-check', { server: 'https://b.test/' })).value + assert.deepEqual({ server: checked.server, name: checked.name, features: checked.features, same: checked.same, known: checked.known }, { server: 'https://b.test', name: 'Bot Social', features: ['presence', 'mentions'], same: false, known: false }) + assert.equal(servers.seen.at(-1).redirect, 'manual') + assert.equal(serverFiles(home).includes('flybot-social-server.json'), false) + + // Switching needs the confirm; without it nothing changes. + const unconfirmed = await call('flybot/social/server-set', { server: 'https://b.test' }) + assert.equal(unconfirmed.ok, false) + assert.match(unconfirmed.error.message, /Confirm/) + assert.equal((await view()).server, 'https://a.test') + // A server that is not Bot Social is refused before anything moves. + servers.servers['https://c.test'] = { platform: false, mock: mockServer({ token: 'tok-c' }) } + const refused = await call('flybot/social/server-set', { server: 'https://c.test', confirm: true }) + assert.equal(refused.ok, false) + assert.match(refused.error.message, /HTTP 404/) + assert.equal(flybot.fields.get('a', 'member').bot_id, 'm1') + + const switched = await call('flybot/social/server-set', { server: 'https://b.test', displayName: 'Danyang B', confirm: true }) + assert.equal(switched.ok, true, JSON.stringify(switched)) + status = switched.value + assert.equal(status.server, 'https://b.test') + assert.equal(status.source, 'settings') + assert.equal(status.state, 'connected') + assert.equal(status.defaultServer, 'https://a.test') + assert.deepEqual(status.known, ['https://a.test']) + assert.deepEqual(status.platform.features, ['presence', 'mentions']) + assert.doesNotMatch(JSON.stringify(status), /tok-[abc]/) + // The old credentials file is byte for byte what it was; the new server has a file of its own. + assert.equal(readFileSync(credentialsPath, 'utf8'), credentialsA) + const credentialsB = join(home, `flybot-social.json.${serverTag('https://b.test')}`) + assert.equal(JSON.parse(readFileSync(credentialsB, 'utf8')).server, 'https://b.test') + assert.equal(JSON.parse(readFileSync(credentialsB, 'utf8')).token, 'tok-b') + if (process.platform !== 'win32') for (const file of [credentialsB, join(home, 'flybot-social-server.json')]) assert.equal(statSync(file).mode & 0o777, 0o600) + assert.equal(servers.mock('https://a.test').onboarded(), 1) + assert.equal(servers.mock('https://b.test').onboarded(), 1) + // Server A's identities are parked, not carried over. + assert.equal(flybot.fields.get('a', 'member'), undefined) + assert.equal(flybot.fields.get('a', 'autoApprove'), undefined) + assert.deepEqual(ctx.flybot.grants.of('a'), []) + const parked = JSON.parse(readFileSync(join(home, `flybot-social-bots.${serverTag('https://a.test')}.json`), 'utf8')) + assert.equal(parked.bots.a.member.bot_id, 'm1') + assert.doesNotMatch(JSON.stringify(parked), /tok-/) + // The social endpoints answer on the new server (the restart left no duplicate or missing handler). + assert.equal((await call('flybot/social/status', {})).ok, true) + + // Same address again: nothing to do. + assert.equal((await call('flybot/social/server-set', { server: 'https://B.test/' })).value.server, 'https://b.test') + assert.equal(servers.mock('https://b.test').onboarded(), 1) + + // Back to the default: the saved credentials and identities return, with no second onboarding. + const back = await call('flybot/social/server-set', { server: 'https://a.test', confirm: true }) + assert.equal(back.ok, true, JSON.stringify(back)) + assert.equal(back.value.source, 'config') + assert.equal(back.value.state, 'connected') + assert.equal(serverFiles(home).includes('flybot-social-server.json'), false) + assert.equal(servers.mock('https://a.test').onboarded(), 1) + assert.equal(flybot.fields.get('a', 'member').bot_id, 'm1') + assert.deepEqual(flybot.fields.get('a', 'autoApprove'), { '@bob.home': true }) + assert.deepEqual(ctx.flybot.grants.of('a').map(grant => grant.grantee), ['@bob.home']) + assert.equal(readFileSync(credentialsPath, 'utf8'), credentialsA) + assert.equal(JSON.parse(readFileSync(credentialsB, 'utf8')).token, 'tok-b') +}) + +test('Settings server: a switch that fails puts the previous server back, with its files and identities', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const servers = fleet({ 'https://a.test': { token: 'tok-a' }, 'https://closed.test': { onboardFails: true } }) + const credentialsPath = join(home, 'flybot-social.json') + const warned = [] + const originalWarn = console.warn + console.warn = (...args) => { warned.push(args.join(' ')) } + t.after(() => { console.warn = originalWarn }) + await applySocial(ctx, { server: 'https://a.test', credentialsPath, fetch: servers.fetch, inboxPollMs: 0 }) + const flybot = ctx.flybot.scope('social') + await flybot.grants.set('a', [{ id: 'g1', grantee: '@bob.home' }]) + flybot.fields.set('a', 'member', { bot_id: 'm1', handle: '@chief.danyang.home' }) + const credentialsA = readFileSync(credentialsPath, 'utf8') + + const failed = await call('flybot/social/server-set', { server: 'https://closed.test', confirm: true }) + assert.equal(failed.ok, false) + assert.match(failed.error.message, /Could not connect to the new server: .*registration is closed/) + assert.match(failed.error.message, /previous server is back/) + const status = (await call('flybot/social/server', {})).value + assert.equal(status.server, 'https://a.test') + assert.equal(status.state, 'connected') + assert.equal(status.source, 'config') + assert.equal(serverFiles(home).includes('flybot-social-server.json'), false) + assert.equal(readFileSync(credentialsPath, 'utf8'), credentialsA) + assert.equal(flybot.fields.get('a', 'member').bot_id, 'm1') + assert.deepEqual(ctx.flybot.grants.of('a').map(grant => grant.grantee), ['@bob.home']) + assert.equal(servers.mock('https://a.test').onboarded(), 1) + assert.equal((await call('flybot/social/status', {})).ok, true) + assert.doesNotMatch(warned.join('\n'), /tok-a/) +}) + +test('Settings server: nothing configured is unchanged, and a first server can be set from Settings', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const servers = fleet({ 'https://a.test': { token: 'tok-a' } }) + const credentialsPath = join(home, 'flybot-social.json') + await applySocial(ctx, { credentialsPath, fetch: servers.fetch, inboxPollMs: 0 }) + assert.deepEqual(serverFiles(home), []) + let status = (await call('flybot/social/server', {})).value + assert.deepEqual({ server: status.server, source: status.source, state: status.state, handle: status.handle, defaultServer: status.defaultServer }, { server: null, source: 'none', state: 'not-configured', handle: null, defaultServer: null }) + assert.equal(servers.seen.length, 0) + assert.equal((await call('flybot/social/server-check', {})).ok, false) + + const set = await call('flybot/social/server-set', { server: 'https://a.test', confirm: true }) + assert.equal(set.ok, true, JSON.stringify(set)) + status = set.value + assert.deepEqual({ server: status.server, source: status.source, state: status.state, handle: status.handle }, { server: 'https://a.test', source: 'settings', state: 'connected', handle: '@danyang.home' }) + // Not the default server, so its credentials sit under the server's own name and the default file stays free. + assert.equal(JSON.parse(readFileSync(`${credentialsPath}.${serverTag('https://a.test')}`, 'utf8')).token, 'tok-a') + assert.equal(serverFiles(home).includes('flybot-social.json'), false) + assert.equal(servers.mock('https://a.test').onboarded(), 1) +}) + +test('Settings server: a restart keeps the chosen server; the profile value is only the default', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const servers = fleet({ 'https://a.test': { token: 'tok-a' }, 'https://b.test': { token: 'tok-b' } }) + const config = { server: 'https://a.test', credentialsPath: join(home, 'flybot-social.json'), fetch: servers.fetch, inboxPollMs: 0 } + await applySocial(ctx, config) + assert.equal((await call('flybot/social/server-set', { server: 'https://b.test', confirm: true })).ok, true) + await ctx.dispose() + const second = fakeContext() + Object.assign(second, { flybot: ctx.flybot }) + await applySocial(second, config) + const status = (await call('flybot/social/server', {})).value + assert.equal(status.server, 'https://b.test') + assert.equal(status.source, 'settings') + assert.equal(status.defaultServer, 'https://a.test') + assert.equal(servers.mock('https://b.test').onboarded(), 1) + await second.dispose() +}) + +test('server controller: per-server files, a hung start is replaced and its late result disposed, read-only refuses', async () => { + const home = mkdtempSync(join(tmpdir(), 'flybot-social-ctl-')) + const starts = [] + const disposed = [] + let hang + const fly = (readOnly = false) => ({ bot: { list: () => [], get: () => undefined }, fields: { get: () => undefined, set() {} }, grants: { of: () => [], set: async () => {} }, readOnly: () => readOnly }) + const base = { server: 'https://a.test', credentialsPath: join(home, 'flybot-social.json'), displayName: 'Me' } + const probe = async server => ({ name: 'Bot Social', features: [], server }) + const start = async (config) => { + starts.push(config) + if (config.server === 'https://slow.test') return new Promise((resolve) => { hang = () => resolve({ home: { handle: '@late.home' }, dispose: () => disposed.push('late') }) }) + return { home: { handle: '@me.home' }, dispose: () => disposed.push(config.server) } + } + try { + const controller = createServerController({ base, flybot: fly(), start, probe, startTimeoutMs: 20 }) + await controller.ready() + assert.equal(starts[0].credentialsPath, base.credentialsPath) + assert.equal(starts[0].settingsPath, undefined) + assert.equal(controller.status().state, 'connected') + // A start that never answers: refused after the deadline, the previous server is started again, the late social is disposed. + await assert.rejects(controller.set({ server: 'https://slow.test', confirm: true }), /did not finish connecting in time.*previous server is back/) + hang() + await new Promise(resolve => setTimeout(resolve, 10)) + assert.deepEqual(disposed, ['https://a.test', 'late']) + assert.equal(controller.status().server, 'https://a.test') + assert.equal(controller.status().state, 'connected') + // Another server starts with its own credentials and settings paths. + await controller.set({ server: 'https://b.test', confirm: true }) + const other = starts.at(-1) + assert.equal(other.credentialsPath, `${base.credentialsPath}.${serverTag('https://b.test')}`) + assert.equal(other.settingsPath, join(home, `flybot-social-settings.${serverTag('https://b.test')}.json`)) + assert.equal(other.displayName, 'Me') + controller.dispose() + // A read-only host (another window holds the team) changes nothing. + const readOnly = createServerController({ base, flybot: fly(true), start, probe }) + assert.equal(readOnly.status().canChange, false) + await assert.rejects(readOnly.set({ server: 'https://b.test', confirm: true }), /Another FlyBot window/) + } finally { + rmSync(home, { recursive: true, force: true }) + } +}) + +// state order in SocialServerSettings: view, loadError, live, form, address, name, checked, busy, error +const serverView = (extra = {}) => ({ server: 'https://a.test', source: 'config', defaultServer: 'https://a.test', displayName: 'Me', state: 'connected', handle: '@me.home', error: null, canChange: true, known: [], ...extra }) +const pageProps = { Section: 'section', Row: 'row', t: (text, params) => String(text).replace(/\{(\w+)\}/g, (_, key) => String(params?.[key] ?? '')) } +const nodeBy = (nodes, id) => nodes.find(node => node.props?.['data-flybot'] === id) + +test('Settings → Bot Social page: shows server, source and status; the switch form opens from Change server', () => { + { + const { plugin, all, text } = clientPlugin([serverView(), null, { ok: true, features: ['presence'] }]) + const nodes = all(plugin.SocialServerSettings(pageProps)) + const rows = nodes.filter(node => node.type === 'row') + assert.equal(rows[0].props.label, 'https://a.test') + assert.equal(rows[0].props.hint, 'Connected as @me.home') + assert.match(text(rows[0]), /Profile default/) + assert.equal(rows.find(row => row.props.label === 'Features').props.hint, 'presence') + assert.ok(nodeBy(nodes, 'server-change')) + assert.equal(nodeBy(nodes, 'server-address'), undefined) + } + { + const { plugin, all } = clientPlugin([serverView({ state: 'error', error: 'HTTP 500' }), null, null, true]) + const nodes = all(plugin.SocialServerSettings(pageProps)) + assert.equal(nodes.find(node => node.type === 'row').props.hint, 'Could not connect: HTTP 500') + assert.ok(nodeBy(nodes, 'server-address')) + assert.equal(nodeBy(nodes, 'server-warning'), undefined) + assert.ok(nodeBy(nodes, 'server-check')) + assert.equal(nodeBy(nodes, 'server-confirm'), undefined) + } + { + const { plugin, all } = clientPlugin([serverView({ server: null, source: 'none', state: 'not-configured', handle: null, defaultServer: null })]) + const row = all(plugin.SocialServerSettings(pageProps)).find(node => node.type === 'row') + assert.equal(row.props.label, 'No server') + assert.match(row.props.hint, /No server set/) + } + { + const { plugin, all } = clientPlugin([serverView({ canChange: false })]) + const nodes = all(plugin.SocialServerSettings(pageProps)) + assert.equal(nodeBy(nodes, 'server-change'), undefined) + assert.ok(nodes.some(node => node.type === 'row' && node.props.label === 'Read-only')) + } +}) + +test('Settings → Bot Social page: a checked server shows the identity warning and a confirm; a known one says it comes back', () => { + const checked = { server: 'https://b.test', name: 'Bot Social', version: '0.2.0', features: ['presence', 'mentions'], same: false, known: false } + { + const { plugin, all, text } = clientPlugin([serverView(), null, null, true, 'https://b.test', '', checked]) + const nodes = all(plugin.SocialServerSettings(pageProps)) + const warning = nodeBy(nodes, 'server-warning') + assert.match(text(warning), /Switching servers changes who you are/) + assert.match(text(warning), /new identity/) + assert.match(text(warning), /backup on this computer and is never deleted/) + assert.match(text(nodeBy(nodes, 'server-checked')), /Bot Social 0\.2\.0 · b\.test/) + assert.match(text(nodeBy(nodes, 'server-checked')), /presence, mentions/) + assert.equal(text(nodeBy(nodes, 'server-confirm')), 'Switch to this server') + assert.equal(nodeBy(nodes, 'server-check'), undefined) + } + { + const { plugin, all, text } = clientPlugin([serverView(), null, null, true, 'https://b.test', '', { ...checked, known: true }]) + assert.match(text(nodeBy(all(plugin.SocialServerSettings(pageProps)), 'server-warning')), /come back/) + } + { + const { plugin, all } = clientPlugin([serverView(), null, null, true, 'https://a.test', '', { ...checked, server: 'https://a.test', same: true }]) + const nodes = all(plugin.SocialServerSettings(pageProps)) + assert.equal(nodeBy(nodes, 'server-warning'), undefined) + assert.equal(nodeBy(nodes, 'server-confirm'), undefined) + } +}) + +test('Settings → Bot Social page: every English line it passes to t() has a Chinese entry in the host dictionary', async () => { + const { DICTIONARY } = await import('../../../core/src/client/i18n.js') + const source = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + const start = source.indexOf('const STATE_TEXT') + const end = source.indexOf('let cache = { at: 0, list: [] }') + const page = source.slice(start, end) + const keys = [...page.matchAll(/\bt\(\s*(['"])((?:\\.|(?!\1).)*)\1/g)].map(match => match[2].replace(/\\'/g, "'")) + assert.ok(keys.length > 20) + assert.deepEqual(keys.filter(key => !Object.hasOwn(DICTIONARY.zh, key)), []) + assert.ok(Object.hasOwn(DICTIONARY.zh, 'Bot Social')) +}) From 865e9a876cac8b1dbd5135e2abd1192528478bda Mon Sep 17 00:00:00 2001 From: Birfy Date: Sun, 18 Oct 2026 20:18:13 +0800 Subject: [PATCH 2/2] Explain the discarded catches in the server switch (#337) --- plugins/social/src/server-config.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/social/src/server-config.js b/plugins/social/src/server-config.js index 517ac04a2..91b6a0b83 100644 --- a/plugins/social/src/server-config.js +++ b/plugins/social/src/server-config.js @@ -259,11 +259,13 @@ export function createServerController({ base, flybot, start, registerUnavailabl } catch (failure) { // Back to the server that worked: its identities, its files, its override. stop() + // A failed start leaves nothing of the new server worth parking: a failed park changes nothing here. if (target.server) await park(target.server).catch(() => undefined) writeOverride(previous) if (previous.server) await restore(previous.server) current = previous lastProbe = undefined + // launch() has already recorded the failure (state, error, the unavailable answers); the message below reports it. await launch(previous).catch(() => undefined) const why = cleanLine(failure?.message ?? failure, 200) || 'the server did not accept this FlyBot' throw refused(`Could not connect to the new server: ${why} The previous server is back${state === 'connected' ? '' : ', but it did not reconnect either'}.`, 'switch_failed')