diff --git a/README.md b/README.md index 74b37f3dc..e1c84c665 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,9 @@ Every local Bot can chat with friends' Bots through a [Bot Social](https://githu *Demo: add a friend, message their Bot, the reply is held by Bot Social because it mentions money, the friend's owner approves it, and it appears in the chat.* [Watch the full video (MP4)](docs/images/demo-social.mp4) - **One-click onboarding.** On first start with a `server` configured, FlyBot calls `POST /v1/onboard/flybot`, creates your home (`@.home`), and stores credentials in `$DSH_HOME/bot/flybot-social.json` (mode 600). -- **Members.** Access is a per-Bot **grant** (default deny, Chief included). A local Bot becomes `@.` on the server when you invite someone to that Bot, when it joins an invite, or when you bring it into a face-to-face room. Friends' Bots lists only Bots granted to you. **Manage access** on a Bot shows who has it and **Revoke**. Deleted Bots are revoked. Ungranted Bots have no outside identity: friends cannot reach them and they cannot message friends. -- **Friends' Bots sidebar.** Friends appear under **Friends' Bots**, grouped by home, with a remote badge. Click one to open a normal chat. Right-click a local Bot → **Copy invite link** (or **Copy invite with instructions**), or use the sidebar **+** → **Add friend** to paste a friend's link and join. **Face-to-face group** and **Friend requests** are on the same **+** menu. -- **Approvals.** Inbound messages from a friend arrive immediately. Outbound replies may wait in **Friends' Bots** (badge + Approve / Reject) until you decide, unless Auto-accept is on for that friend. FlyBot also holds a Bot's own send behind an approval card; files are always held. +- **Members.** Access is a per-Bot **grant** (default deny, Chief included). A local Bot becomes `@.` on the server when you invite someone to that Bot, when it joins an invite, or when you bring it into a face-to-face room. The sidebar lists only Bots granted to you. **Manage access** on a Bot shows who has it and **Revoke**. Deleted Bots are revoked. Ungranted Bots have no outside identity: friends cannot reach them and they cannot message friends. +- **Friends in the Bot list.** A friend's Bot sits in the same sidebar list as yours, with a small globe after the name. Face-to-face rooms sit there too, with a people mark; ended ones fold under **Past rooms**. Click a row to open a normal chat. Right-click a local Bot → **Copy invite link** (or **Copy invite with instructions**), or use the sidebar **+** → **Add friend** to paste a friend's link and join. **Face-to-face group**, **Copy invite link**, and **Copy my handle** are on the same **+** menu. **Auto-send** for a friend is on that Bot's settings. +- **Approvals.** Inbound messages from a friend arrive immediately. Outbound replies may wait in the open chat (badge + Approve / Reject) until you decide, unless Auto-send is on for that friend. FlyBot also holds a Bot's own send behind an approval card; files are always held. - **Tools.** Every local Bot gets `list_friend_bots` and `message_friend_bot` (with optional file attachments). The social plugin stays idle until you point it at a Bot Social server (see [Quick Start](#quick-start) below). @@ -119,9 +119,9 @@ Point FlyBot at that server in `~/.dsh/profiles/web/cordis.patch.yml`, then rest displayName: Your Name ``` -On first start FlyBot registers your home. No Bot is shared until you grant it. If that home handle is already taken, FlyBot retries a suffixed name (`Your Name2`, …) instead of asking you to edit this config. **Friends' Bots** shows your connected handle with Copy. To grant a Bot: right-click that Bot → **Invite to this Bot**, send the link; your friend pastes it under the sidebar **+** → **Add friend**. Accepting the invite grants that Bot only. +On first start FlyBot registers your home. No Bot is shared until you grant it. If that home handle is already taken, FlyBot retries a suffixed name (`Your Name2`, …) instead of asking you to edit this config. **Copy my handle** on the sidebar **+** copies your connected handle. To grant a Bot: right-click that Bot → **Invite to this Bot**, send the link; your friend pastes it under the sidebar **+** → **Add friend**. Accepting the invite grants that Bot only. -A friend's inbound messages arrive immediately. What your Bots send back waits in the open chat until you Approve (or Reject). Auto-send for a new friend is off until you turn it on. If a friend sees no reply, look for the **to approve** badge in **Friends' Bots**. +A friend's inbound messages arrive immediately. What your Bots send back waits in the open chat until you Approve (or Reject). Auto-send for a new friend is off until you turn it on. If a friend sees no reply, look for the **to approve** badge. Without a `server`, the social plugin stays idle by design. Tree works without Bot Social. diff --git a/README.zh.md b/README.zh.md index 817c93cb1..c127345f9 100644 --- a/README.zh.md +++ b/README.zh.md @@ -62,9 +62,9 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 plugin --profile web add "github:ThinkFlowLab *演示:添加好友、给对方的 Bot 发消息,回复因含金额被 Bot Social 暂扣,好友的主人批准后送达对话。* [观看完整视频(MP4)](docs/images/demo-social.mp4) - **一键入驻。** 配置好 `server` 后首次启动会调用 `POST /v1/onboard/flybot`,创建家(`@.home`),凭证写在 `$DSH_HOME/bot/flybot-social.json`(权限 600)。 -- **成员。** 访问是按 Bot 的**授权**(默认拒绝,Chief 也一样)。本地 Bot 在你邀请别人使用它、它加入邀请、或你把它带进面对面房间时,才会在服务器上成为 `@.`。**「好友的 Bot」** 只列出别人授权给你的 Bot。每个 Bot 的 **管理访问** 列出谁有它,并可 **撤销**。删除 Bot 会撤销其身份。未授权的 Bot 没有对外身份:朋友够不到它,它也不能给朋友发消息。 -- **「好友的 Bot」侧栏。** 按家分组,带远端标记;点开就是普通对话。本地 Bot 右键 → **复制邀请链接**(或带说明的邀请),或用侧栏 **+** → **添加好友** 粘贴好友的链接加入。**面对面建群** 和 **好友请求** 也在同一个 **+** 菜单里。 -- **审批。** 好友发来的消息会立刻送达。你的 Bot 发出的回复可能停在 **「好友的 Bot」**(角标 + 批准 / 拒绝),除非对该好友开了自动接受。FlyBot 本地也会暂扣 Bot 自己发起的发送;文件一律先审批。 +- **成员。** 访问是按 Bot 的**授权**(默认拒绝,Chief 也一样)。本地 Bot 在你邀请别人使用它、它加入邀请、或你把它带进面对面房间时,才会在服务器上成为 `@.`。侧栏只列出别人授权给你的 Bot。每个 Bot 的 **管理访问** 列出谁有它,并可 **撤销**。删除 Bot 会撤销其身份。未授权的 Bot 没有对外身份:朋友够不到它,它也不能给朋友发消息。 +- **侧栏里的好友。** 好友的 Bot 和你自己的 Bot 在同一列表,名字后面有一个小地球标记。面对面房间也在这份列表里,带一个小人标记;已结束的折在 **过去的房间** 下。点开就是普通对话。本地 Bot 右键 → **复制邀请链接**(或带说明的邀请),或用侧栏 **+** → **添加好友** 粘贴好友的链接加入。**面对面建群**、**复制邀请链接** 和 **复制我的 handle** 也在同一个 **+** 菜单里。对该好友的 **自动发送** 在那个 Bot 的设置里。 +- **审批。** 好友发来的消息会立刻送达。你的 Bot 发出的回复可能停在当前聊天里等你批准(或拒绝),除非对该好友开了自动发送。FlyBot 本地也会暂扣 Bot 自己发起的发送;文件一律先审批。 - **工具。** 每个本地 Bot 都有 `list_friend_bots` 与 `message_friend_bot`(可带附件)。 在指向 Bot Social 服务器之前,社交插件会保持空闲(见下方快速开始)。 @@ -114,9 +114,9 @@ export PORT=8787 displayName: 你的名字 ``` -首次启动只登记你家。在你授权之前,没有任何 Bot 被共享。若家句柄已被占用,FlyBot 会自动改用带后缀的名字(`你的名字2` …),而不用你去改配置。**「好友的 Bot」** 会显示你的句柄并提供复制。授权某个 Bot:右键该 Bot → **邀请使用此 Bot**,把链接发给对方;对方在侧栏 **+** → **添加好友** 里粘贴。接受邀请只授权这一个 Bot。 +首次启动只登记你家。在你授权之前,没有任何 Bot 被共享。若家句柄已被占用,FlyBot 会自动改用带后缀的名字(`你的名字2` …),而不用你去改配置。侧栏 **+** 里的 **复制我的 handle** 会复制你的句柄。授权某个 Bot:右键该 Bot → **邀请使用此 Bot**,把链接发给对方;对方在侧栏 **+** → **添加好友** 里粘贴。接受邀请只授权这一个 Bot。 -好友发来的消息会立刻送达。你的 Bot 回过去的内容会停在当前聊天里等你批准(或拒绝)。新好友默认关闭自动发送。如果对方只看见自己的话,先看 **「好友的 Bot」** 里的 **待批准** 角标。 +好友发来的消息会立刻送达。你的 Bot 回过去的内容会停在当前聊天里等你批准(或拒绝)。新好友默认关闭自动发送。如果对方只看见自己的话,先看 **待批准** 角标。 未配置 `server` 时,社交插件会故意保持空闲。树插件不依赖 Bot Social。 diff --git a/changelog.d/sidebar-unified-friends.changed.md b/changelog.d/sidebar-unified-friends.changed.md new file mode 100644 index 000000000..2bcdd402f --- /dev/null +++ b/changelog.d/sidebar-unified-friends.changed.md @@ -0,0 +1 @@ +Friends' Bots and live face-to-face rooms sit in the same sidebar list as your own Bots and groups (a small globe or people mark). **Past rooms**, **N to approve**, and the + panels stay in the social strip. **Copy my handle** is on the sidebar **+**; per-friend **Auto-send** and **Remove this Bot** are on that friend's Bot settings. The separate Friends' Bots section is gone. A group @ of a friend's Bot that is not in the room still posts a mention receipt; the sent line points at that Bot's own chat. diff --git a/core/client.js b/core/client.js index ff8175582..f37a19668 100644 --- a/core/client.js +++ b/core/client.js @@ -109,6 +109,9 @@ var ZH = { "Paste a friend’s invite link or code.": "粘贴朋友的邀请链接或邀请码。", "Copy invite link": "复制邀请链接", "Copy a link a friend can paste in Add friend.": "复制链接,朋友在添加好友里粘贴即可。", + "Copy my handle": "复制我的 handle", + "Your Bot Social handle": "你的 Bot Social handle", + "Face-to-face": "面对面", "This Bot has no model set up yet.": "这个 Bot 还没有配置模型。", "This Bot could not answer right now.": "这个 Bot 现在没法回答。", "Refreshing…": "正在刷新…", @@ -1882,13 +1885,13 @@ function FlyBotRoomOverview({ room: room2, roster, actions }) { (0, import_react2.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2 } }) ))); } -function FlyBotDetails({ bot, roster }) { +function FlyBotDetails({ bot, roster, actions }) { const entries2 = useSlot("flybot.bot.details"); if (entries2.length === 0) return null; return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( "div", { key: entry.id, className: "bt-flybot-details", "data-flybot": entry.id }, - (0, import_react2.createElement)(Safe, { entry, props: { bot, roster, call: call2 } }) + (0, import_react2.createElement)(Safe, { entry, props: { bot, roster, actions, call: call2 } }) ))); } var POLL_MS = 5e3; @@ -5620,6 +5623,26 @@ function RemoteMark({ remote }) { ) ); } +function isPastFaceRoom(room2) { + if (room2?.origin !== "face-to-face") return false; + const status = room2.face?.status; + if (status === "closed" || status === "left") return true; + const expires = room2.face?.expiresAt; + return status === "candidate" && expires && Number.isFinite(Date.parse(expires)) && Date.parse(expires) <= Date.now(); +} +function FaceMark() { + return (0, import_react9.createElement)( + "span", + { className: "bt-row-remote", title: t("Face-to-face"), "aria-label": t("Face-to-face") }, + (0, import_react9.createElement)( + "svg", + { width: 11, height: 11, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react9.createElement)("circle", { cx: 9, cy: 8, r: 3 }), + (0, import_react9.createElement)("circle", { cx: 16, cy: 9, r: 2.5 }), + (0, import_react9.createElement)("path", { d: "M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0" }) + ) + ); +} function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { const id = room2.id; const fallback = useSessions((list) => nameMentions(previewOf(list, id), roster)); @@ -5635,7 +5658,8 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, const pinned = room2.pinned === true; const title = room2.displayName || (room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2.name); const waiting = approval || (room2.pendingCount ?? 0) > 0; - const label = [title, pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", waiting ? t("Waiting for your approval") : ""].filter(Boolean).join(", "); + const face = room2.origin === "face-to-face"; + const label = [title, face ? t("Face-to-face") : "", pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", waiting ? t("Waiting for your approval") : ""].filter(Boolean).join(", "); return (0, import_react9.createElement)( "button", { @@ -5655,7 +5679,7 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, wide ? (0, import_react9.createElement)( "span", { className: "bt-row-body" }, - (0, import_react9.createElement)("span", { className: "bt-row-name" }, (0, import_react9.createElement)("span", { className: "bt-row-title" }, title), room2.sharedFrom ? (0, import_react9.createElement)("span", { className: "bt-tag" }, t("Shared")) : null, pinned ? (0, import_react9.createElement)(PinMark) : null), + (0, import_react9.createElement)("span", { className: "bt-row-name" }, (0, import_react9.createElement)("span", { className: "bt-row-title" }, title), face ? (0, import_react9.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react9.createElement)("span", { className: "bt-tag" }, t("Shared")) : null, pinned ? (0, import_react9.createElement)(PinMark) : null), workingLine ? (0, import_react9.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react9.createElement)("span", { className: "bt-row-preview" }, preview) : null ) : null, wide && waiting && !active ? (0, import_react9.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react9.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null @@ -5872,19 +5896,22 @@ function TeamSidebar(props) { const order = (0, import_react9.useMemo)(() => { const stamps = JSON.parse(updated); const entries2 = [ - // Friends' Bots (remote chats) are listed by their plugin's section, not with the team. - ...roster.bots.filter((bot) => bot.id !== roster.mainBotId && !bot.remote).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), - ...roster.rooms.map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), + // Own Bots, friends' Bots, groups and face rooms share one list. A remote chat + // still uses BotRow's globe; gone / hidden / home-level remotes stay out. + ...roster.bots.filter((bot) => bot.id !== roster.mainBotId && isListedRemoteChat(bot)).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), + ...roster.rooms.filter((room2) => !isPastFaceRoom(room2)).map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), // The Agent sorts by its most recent Session, falling back to when it was added. ...(roster.agents ?? []).map((agent) => ({ kind: "agent", item: agent, at: Math.max(0, ...agent.sessions.map((id) => stamps[id] ?? 0)) || agent.createdAt })) ]; - return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || b.at - a.at); + const foreign = (entry) => entry.kind === "bot" && entry.item.remote || entry.kind === "room" && (entry.item.origin === "face-to-face" || entry.item.sharedFrom); + return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || Number(foreign(a)) - Number(foreign(b)) || b.at - a.at); }, [roster, updated]); const main = roster.byId[roster.mainBotId]; const structure = useSidebarStructure(); const [collapsed, setCollapsed] = (0, import_react9.useState)({}); const toggle = (id) => setCollapsed((value) => ({ ...value, [id]: !value[id] })); - const tree = structure && wide && main ? botTree([main, ...order.filter((entry) => entry.kind === "bot").map((entry) => entry.item)], main.id, (id) => structure.parentOf(id)) : null; + const inTree = (entry) => entry.kind === "bot" && !entry.item.remote; + const tree = structure && wide && main ? botTree([main, ...order.filter(inTree).map((entry) => entry.item)], main.id, (id) => structure.parentOf(id)) : null; useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); useLandOnMainBot(roster, useSessions, actions); const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; @@ -5915,7 +5942,7 @@ function TeamSidebar(props) { "div", { ref: listRef, className: "bt-list", role: "region", "aria-label": t("Bot list") }, tree ? (0, import_react9.createElement)("div", { className: "bt-tree", role: "group", "aria-label": t("Bot tree") }, tree.map((node) => (0, import_react9.createElement)(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions }))) : main ? (0, import_react9.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react9.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), - order.filter((entry) => !(tree && entry.kind === "bot")).map((entry) => entry.kind === "bot" ? (0, import_react9.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react9.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react9.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })), + order.filter((entry) => !(tree && inTree(entry))).map((entry) => entry.kind === "bot" ? (0, import_react9.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react9.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react9.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })), wide ? (0, import_react9.createElement)(FlyBotSections, { roster, actions, renderBot: (bot) => (0, import_react9.createElement)(BotRow, { key: bot.id, ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }) }) : null ) ); @@ -8970,7 +8997,7 @@ function BotSettingsForm({ bot, roster, actions, run }) { (0, import_react19.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }), isMainBot ? null : (0, import_react19.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) }) ), - (0, import_react19.createElement)(FlyBotDetails, { bot, roster }), + (0, import_react19.createElement)(FlyBotDetails, { bot, roster, actions }), (0, import_react19.createElement)( Card, { title: t("Manage") }, diff --git a/core/src/client/bot-settings.js b/core/src/client/bot-settings.js index 65c268530..2225f9fed 100644 --- a/core/src/client/bot-settings.js +++ b/core/src/client/bot-settings.js @@ -247,7 +247,7 @@ export function BotSettingsForm({ bot, roster, actions, run }) { isTile ? null : h(Card, { title: t('Sidebar') }, h(SwitchRow, { label: t('Pin'), hint: t('Kept above the other chats.'), on: bot.pinned === true, onChange: pinned => run(actions.setFlags(bot.id, { pinned })) }), isMainBot ? null : h(SwitchRow, { label: t('Hide from sidebar'), hint: t('Search still finds it.'), on: bot.hidden === true, onChange: hidden => run(actions.setFlags(bot.id, { hidden })) })), - h(FlyBotDetails, { bot, roster }), + h(FlyBotDetails, { bot, roster, actions }), h(Card, { title: t('Manage') }, h('button', { type: 'button', className: 'bt-set-row bt-bs-row', onClick: duplicate }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Duplicate')), h('span', { className: 'bt-set-hint' }, t('A copy with the same job and model, and fresh memory.'))), diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index 366b5315e..7efa682b4 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -105,11 +105,11 @@ export function FlyBotRoomOverview({ room, roster, actions }) { h(Safe, { entry, props: { room, roster, actions, call } })))) } -export function FlyBotDetails({ bot, roster }) { +export function FlyBotDetails({ bot, roster, actions }) { const entries = useSlot('flybot.bot.details') if (entries.length === 0) return null return h(Fragment, null, entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-details', 'data-flybot': entry.id }, - h(Safe, { entry, props: { bot, roster, call } })))) + h(Safe, { entry, props: { bot, roster, actions, call } })))) } const POLL_MS = 5000 diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 19b22ba65..b7cb09327 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -72,6 +72,9 @@ const ZH = { "Paste a friend’s invite link or code.": '粘贴朋友的邀请链接或邀请码。', 'Copy invite link': '复制邀请链接', 'Copy a link a friend can paste in Add friend.': '复制链接,朋友在添加好友里粘贴即可。', + 'Copy my handle': '复制我的 handle', + 'Your Bot Social handle': '你的 Bot Social handle', + 'Face-to-face': '面对面', 'This Bot has no model set up yet.': '这个 Bot 还没有配置模型。', 'This Bot could not answer right now.': '这个 Bot 现在没法回答。', 'Refreshing…': '正在刷新…', diff --git a/core/src/client/sidebar.js b/core/src/client/sidebar.js index 87061521c..4c91a5ccb 100644 --- a/core/src/client/sidebar.js +++ b/core/src/client/sidebar.js @@ -95,6 +95,21 @@ function RemoteMark({ remote }) { h('circle', { cx: 12, cy: 12, r: 9 }), h('path', { d: 'M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18' }))) } +function isPastFaceRoom(room) { + if (room?.origin !== 'face-to-face') return false + const status = room.face?.status + if (status === 'closed' || status === 'left') return true + const expires = room.face?.expiresAt + return status === 'candidate' && expires && Number.isFinite(Date.parse(expires)) && Date.parse(expires) <= Date.now() +} + +function FaceMark() { + return h('span', { className: 'bt-row-remote', title: t('Face-to-face'), 'aria-label': t('Face-to-face') }, + h('svg', { width: 11, height: 11, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', 'aria-hidden': true }, + h('circle', { cx: 9, cy: 8, r: 3 }), h('circle', { cx: 16, cy: 9, r: 2.5 }), + h('path', { d: 'M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0' }))) +} + function RoomRow({ room, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { const id = room.id const fallback = useSessions(list => nameMentions(previewOf(list, id), roster)) @@ -110,7 +125,8 @@ function RoomRow({ room, roster, actions, useSessions, useSessionStatus, useSess const pinned = room.pinned === true const title = room.displayName || (room.sharedFrom ? sharedRoomTitle(room.sharedFrom.inviterName) : room.name) const waiting = approval || (room.pendingCount ?? 0) > 0 - const label = [title, pinned ? t('Pinned') : '', members.length > 0 ? t('Working') : '', waiting ? t('Waiting for your approval') : ''].filter(Boolean).join(', ') + const face = room.origin === 'face-to-face' + const label = [title, face ? t('Face-to-face') : '', pinned ? t('Pinned') : '', members.length > 0 ? t('Working') : '', waiting ? t('Waiting for your approval') : ''].filter(Boolean).join(', ') return h('button', { type: 'button', className: 'bt-row', 'aria-label': label, 'aria-current': active ? 'page' : undefined, 'data-room-id': id, onClick: () => actions.openSession(id), @@ -119,7 +135,7 @@ function RoomRow({ room, roster, actions, useSessions, useSessionStatus, useSess h(RoomAvatar, { room, roster, size: 36 }), pinned && !wide ? h(PinMark) : null, wide ? h('span', { className: 'bt-row-body' }, - h('span', { className: 'bt-row-name' }, h('span', { className: 'bt-row-title' }, title), room.sharedFrom ? h('span', { className: 'bt-tag' }, t('Shared')) : null, pinned ? h(PinMark) : null), + h('span', { className: 'bt-row-name' }, h('span', { className: 'bt-row-title' }, title), face ? h(FaceMark) : room.sharedFrom ? h('span', { className: 'bt-tag' }, t('Shared')) : null, pinned ? h(PinMark) : null), workingLine ? h('span', { className: 'bt-row-preview' }, workingLine) : preview ? h('span', { className: 'bt-row-preview' }, preview) : null) : null, wide && waiting && !active ? h('span', { className: 'bt-unread bt-unread-wait', 'aria-hidden': true }) : wide && unread && !active ? h('span', { className: 'bt-unread', 'aria-hidden': true }) : null) @@ -288,20 +304,24 @@ export function TeamSidebar(props) { const order = useMemo(() => { const stamps = JSON.parse(updated) const entries = [ - // Friends' Bots (remote chats) are listed by their plugin's section, not with the team. - ...roster.bots.filter(bot => bot.id !== roster.mainBotId && !bot.remote).map(bot => ({ kind: 'bot', item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), - ...roster.rooms.map(room => ({ kind: 'room', item: room, at: stamps[room.id] ?? room.createdAt })), + // Own Bots, friends' Bots, groups and face rooms share one list. A remote chat + // still uses BotRow's globe; gone / hidden / home-level remotes stay out. + ...roster.bots.filter(bot => bot.id !== roster.mainBotId && isListedRemoteChat(bot)).map(bot => ({ kind: 'bot', item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), + ...roster.rooms.filter(room => !isPastFaceRoom(room)).map(room => ({ kind: 'room', item: room, at: stamps[room.id] ?? room.createdAt })), // The Agent sorts by its most recent Session, falling back to when it was added. ...(roster.agents ?? []).map(agent => ({ kind: 'agent', item: agent, at: Math.max(0, ...agent.sessions.map(id => stamps[id] ?? 0)) || agent.createdAt })), ] - return entries.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || b.at - a.at) + const foreign = entry => (entry.kind === 'bot' && entry.item.remote) || (entry.kind === 'room' && (entry.item.origin === 'face-to-face' || entry.item.sharedFrom)) + return entries.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || Number(foreign(a)) - Number(foreign(b)) || b.at - a.at) }, [roster, updated]) const main = roster.byId[roster.mainBotId] // A plugin (the tree) may give the Bots a structure: the list then is one tree under the Main Bot. const structure = useSidebarStructure() const [collapsed, setCollapsed] = useState({}) const toggle = id => setCollapsed(value => ({ ...value, [id]: !value[id] })) - const tree = structure && wide && main ? botTree([main, ...order.filter(entry => entry.kind === 'bot').map(entry => entry.item)], main.id, id => structure.parentOf(id)) : null + // Only own Bots belong to the tree; a friend's Bot stays in the ordered list below it. + const inTree = entry => entry.kind === 'bot' && !entry.item.remote + const tree = structure && wide && main ? botTree([main, ...order.filter(inTree).map(entry => entry.item)], main.id, id => structure.parentOf(id)) : null useListGlide(listRef, wide, order.map(entry => entry.item.id).join(',')) useLandOnMainBot(roster, useSessions, actions) const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide } @@ -325,7 +345,7 @@ export function TeamSidebar(props) { h('div', { ref: listRef, className: 'bt-list', role: 'region', 'aria-label': t('Bot list') }, tree ? h('div', { className: 'bt-tree', role: 'group', 'aria-label': t('Bot tree') }, tree.map(node => h(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions }))) : main ? h(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : h('div', { className: 'bt-hint' }, t('Setting up your Main Bot…')), - order.filter(entry => !(tree && entry.kind === 'bot')).map(entry => (entry.kind === 'bot' + order.filter(entry => !(tree && inTree(entry))).map(entry => (entry.kind === 'bot' ? h(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === 'room' ? h(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 4a13d8368..63eb44ac9 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -791,7 +791,7 @@ export function install(rt) { return `${name}: not sent${shown ? ` — ${shown}` : ''}` } if (action === 'hold') return `${name}: held for your approval${why ? ` — ${why}` : ''}` - return `${name}: sent — its replies appear in its own chat under Friends' Bots, not in this group` + return `${name}: sent — its replies appear in its own chat, not in this group` }) try { const agent = await rt.resolveAgent(sessionId) diff --git a/core/test/flybot.test.js b/core/test/flybot.test.js index 56e768232..ae0c13c97 100644 --- a/core/test/flybot.test.js +++ b/core/test/flybot.test.js @@ -378,16 +378,26 @@ test('mentions in a group (#80, #81): the room gets a receipt — sent points to fb.transports.add('bot-social', { send: async msg => { out.push(msg); return { exchangeId: `r${out.length}` } } }) fb.scope('social').remote.publish([{ handle: '@helper.friend.home', name: '助手', home: '@friend.home' }]) let seq = 0 - const say = async text => { + const receipts = () => (agents.get('room-1')?.calls ?? []).map(([, m]) => m.content.map(b => b.text).join('\n')) + // Hold persists the approval (load/save + taskHeld) and can race remote-chat open + // after publish; wait for the receipt instead of a fixed 30 ms. + const until = async (check) => { + const end = Date.now() + 2000 + while (!check()) { + if (Date.now() > end) throw new Error('until: the condition did not hold within 2 s') + await delay(10) + } + } + const say = async (text, n) => { ctx.emitArgs('session/event', { id: 'room-1' }, { type: 'user/message', seq: ++seq, data: { source: { kind: 'user' }, content: [{ type: 'text', text }] } }) - await delay(30) + if (n === undefined) await delay(30) + else await until(() => receipts().length >= n) } - const receipts = () => (agents.get('room-1')?.calls ?? []).map(([, m]) => m.content.map(b => b.text).join('\n')) - await say('@helper.friend.home 你好') + await say('@helper.friend.home 你好', 1) assert.equal(out.length, 1) // One receipt, where the reply lands (#80): a friend's Bot's answer never re-enters the group. assert.equal(receipts().length, 1) - assert.match(receipts()[0], /助手: sent — its replies appear in its own chat under Friends' Bots, not in this group/) + assert.match(receipts()[0], /助手: sent — its replies appear in its own chat, not in this group/) const posted = agents.get('room-1').calls[0][1] assert.equal(posted.source.kind, 'bot') assert.equal(posted.source.role, 'mention') @@ -396,13 +406,14 @@ test('mentions in a group (#80, #81): the room gets a receipt — sent points to // A plain message gets no receipt. await say('no mentions here') assert.equal(receipts().length, 1) - // A hold (#81) says what is waiting and where. + // A hold (#81) says what is waiting and where. Friend is not a room participant, so + // #143/#144's in-room skip does not apply: the 1:1 send still runs and the room hears why. fb.bot.send.use({ name: 'deny-some', handle: m => (m.target?.remote === true && /no/.test(m.text) ? { action: 'deny', reason: 'no friendship' } : undefined) }) fb.bot.send.use({ name: 'hold-remote', handle: m => (m.target?.remote === true ? { action: 'hold', reason: 'check with the user' } : undefined) }) - await say('@helper.friend.home say no') + await say('@helper.friend.home say no', 2) assert.equal(out.length, 1) assert.match(receipts()[1], /助手: not sent — no friendship/) - await say('@helper.friend.home say yes') + await say('@helper.friend.home say yes', 3) assert.equal(out.length, 1) assert.match(receipts()[2], /助手: held for your approval — check with the user/) // A mention from a Bot's own chat stays as it was: no receipt outside a group. diff --git a/core/test/new-chat.test.js b/core/test/new-chat.test.js index 101ad628d..efcbeb41a 100644 --- a/core/test/new-chat.test.js +++ b/core/test/new-chat.test.js @@ -56,7 +56,15 @@ test('the sidebar + menu wires plugin actions in after Create group chat', () => const slots = readFileSync(new URL('../src/client/flybot-slots.js', import.meta.url), 'utf8') assert.match(slots, /isListedRemoteChat/) const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') - assert.match(sidebar, /isListedRemoteChat/) + assert.match(sidebar, /isListedRemoteChat\(bot\)/) + assert.match(sidebar, /!isPastFaceRoom\(room\)/) + assert.match(sidebar, /Number\(foreign\(a\)\) - Number\(foreign\(b\)\)/) + // With @flybot/tree on, a friend's Bot is not a tree node under Main; it stays in the flat list. + assert.match(sidebar, /const inTree = entry => entry\.kind === 'bot' && !entry\.item\.remote/) + assert.match(sidebar, /botTree\(\[main, \.\.\.order\.filter\(inTree\)/) + assert.match(sidebar, /order\.filter\(entry => !\(tree && inTree\(entry\)\)\)/) + assert.equal(DICTIONARY.zh['Copy my handle'], '复制我的 handle') + assert.equal(DICTIONARY.zh['Face-to-face'], '面对面') assert.equal(DICTIONARY.zh['Face-to-face group'], '面对面建群') assert.equal(DICTIONARY.zh['Create group chat'], '新建群聊') assert.match(DICTIONARY.zh['Meet the people next to you. Pick your Bots and type the same 6-digit number.'], /身边/) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 8905b51bd..ffb9ac089 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -24,7 +24,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | File | Role | | --- | --- | | `core/src/client/` | Client sources → build to `core/client.js`. | -| `core/src/client/new-chat.js` | Sidebar **+** picker. Plugin rows via `flybot.new-chat.action` (`newChatActionRows` in `flybot-registry.js`); social adds Face-to-face group, Add friend, Accept invite, and Copy invite link. The short + menu does not list individual Bots. | +| `core/src/client/new-chat.js` | Sidebar **+** picker. Plugin rows via `flybot.new-chat.action` (`newChatActionRows` in `flybot-registry.js`); social adds Face-to-face group, Add friend, Accept invite, Copy invite link, and Copy my handle. The short + menu does not list individual Bots. | | `core/test/new-chat.test.js` | Picker filters and the **+** short action menu (`data-menu`; New chat, then Create group chat, plugin actions, then Add Agent). | ## Invariants @@ -65,6 +65,10 @@ The default card (`flybot-slots.js`) shows where a held request came from: `send - User-visible chrome says **FlyBot** (header mark + name, About, settings title, tab title, favicon, zh copy). The mark is the existing FlyBot logo (`docs/images/logo.svg`), inlined as `FlyBotMark` from `src/client/brand.js`. Update popups say **FlyBot core {version}**. The npm package remains `ds-bot`; plugin id, localStorage keys, and `console.warn('[ds-bot]')` stay upstream. +### One sidebar list + +- `TeamSidebar` lists local Bots, listed remote chats (`isListedRemoteChat`), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark`. Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). + ### Approval wait badge on chat rows - A Bot row uses the same gold wait dot as a room row when `bot.pendingCount > 0` (Bot Social holds on that friend's chat) or a local hold is open. Shared-room overview pending lines stay one line with ellipsis (`bt-shared-wait`). @@ -75,7 +79,7 @@ The default card (`flybot-slots.js`) shows where a held request came from: `send ### New-chat menu -- 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, Add friend, Accept invite, and Copy invite link; see [plugin-runtime](plugin-runtime.md) and [social](social.md). +- 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, Add friend, Accept invite, Copy invite link, and Copy my handle; 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 Agent**; **Add DSH Agent** stays the DSH product string on Agent rows elsewhere (#126). - Group bubbles, notices, and failed-reply lines never show a raw `` wrap or a developer error (`llm-deepseek: no API key`). `remoteMessageText` unwraps stored fences; `failureLabel` maps missing-key / unknown model failures to a short friendly line. Details stay in Settings → Feedback. diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 0cc091298..e38efc7c9 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -60,7 +60,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots ### Sidebar + friend actions -- Social may register several `flybot.new-chat.action` rows (Face-to-face group, Add friend, Accept invite, Copy invite link). Each `run` closes the picker; Copy invite link mints and copies in that click and may return a toast string. Still not team mode. +- Social may register several `flybot.new-chat.action` rows (Face-to-face group, Add friend, Accept invite, Copy invite link, Copy my handle). Each `run` closes the picker; Copy invite link mints and copies in that click and may return a toast string; Copy my handle copies `@your.home` and the toast repeats it. Still not team mode. ### Inbound shared rooms on `flybot.rooms` diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 1f515461b..ce9ae8922 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -8,9 +8,9 @@ owned_paths: The social plugin talks to a [Bot Social](https://github.com/Birfy/bot-social) server that you self-host. See the Bot Social README for install and configuration. -Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`. Inbound friend messages arrive immediately; outbound drafts the server holds (`pending_approval`) are listed on `social/status` and approved in the open chat (`flybot.session.dock`). Friends' Bots shows a badge and a one-line jump list. The same queue is also attached to a native shared room via `flybot.roomApprovals.setProvider`, so an invited owner sees “N to approve” in that room without auto-send. +Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`. Inbound friend messages arrive immediately; outbound drafts the server holds (`pending_approval`) are listed on `social/status` and approved in the open chat (`flybot.session.dock`). The social sidebar strip shows a badge and a one-line jump list. The same queue is also attached to a native shared room via `flybot.roomApprovals.setProvider`, so an invited owner sees “N to approve” in that room without auto-send. -Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, next to Create group chat; `label` / `hint` use the same `t()` map). **Face-to-face group**, **Add friend**, **Accept invite**, and **Copy invite link** are those rows; Friends' Bots has no **+ Add** button. **Face-to-face group** opens the same screen (pick one or more local Bots, six-digit number, join with no preview, live waiting room, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its published look or a handle-seeded look, plus any avatar face-room `members[]` carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief · BoxB`); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** is one compact checkbox (tooltip) on the Face-to-face rooms header, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, Friends' Bots rows, and core group overview uses the host `BotAvatar` (chat record, optional member `avatar`, else a handle-seeded look). **Invite to this Bot** creates a per-Bot grant when the friend accepts; **Manage access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** declines that grant on Bot Social (`decline_bot_grant`) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle, or an explicit re-share / `regranted` join. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). +Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, next to Create group chat; `label` / `hint` use the same `t()` map). **Face-to-face group**, **Add friend**, **Accept invite**, **Copy invite link**, and **Copy my handle** are those rows; the social strip has no **+ Add** button. **Face-to-face group** opens the same screen (pick one or more local Bots, six-digit number, join with no preview, live waiting room, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its published look or a handle-seeded look, plus any avatar face-room `members[]` carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief · BoxB`); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** for face rooms is one compact checkbox (tooltip) on the open room's overview, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, **main sidebar row**, and core group overview uses the host `BotAvatar` (published look, optional member `avatar`, else a handle-seeded look). **Invite to this Bot** creates a per-Bot grant when the friend accepts; **Manage access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** declines that grant on Bot Social (`decline_bot_grant`) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle, or an explicit re-share / `regranted` join. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). ## Owns @@ -31,7 +31,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers | `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/client.js` | Friends' Bots: badge, Remove this Bot, cached roster / Refreshing…, header without + Add; sidebar + Face-to-face group / Add friend / Accept invite / Copy invite link (tile grid, 6-digit join, Enter chat + Leave footer), one-line face-room rows + Past rooms, in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid then Leave / Auto-send, Bot settings Invite / Manage access, Add friend mint-and-copy. | +| `plugins/social/client.js` | Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face group / Add friend / Accept invite / Copy invite link / Copy my handle; in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid then Leave / Auto-send; Bot settings Invite / Manage access, or Auto-send + Remove this Bot on a friend's Bot. | ## Entry points @@ -43,19 +43,19 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers 2. Uses documented plugin APIs only. 3. A server policy pause (`conversation_paused`: turn budget reached, loop suspected, emergency stop) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to an error that says resending cannot help and names the Bot Social console, and no other member Bot is tried for it. Other `sendChat` failures are one short English line with a next step (reconnect, invite, try again) — never `Bot Social send_as_member: …`. A human's own message in a face-to-face or shared room to a friend's Bot that is already a member of that room is delivered as that human in the room (`onFaceLine` / room log); it is not signed or grant-checked as Chief (#143). Chief-originated `bot.send` / `message_friend_bot` to an ungranted friend Bot stays denied. 4. Access is a per-Bot **grant** (#90, #138): default deny, Chief included. `register` / `registerAll` register a Bot when it is invited, when it joins an invite, when it already has a person-grant or a saved identity, or at face-to-face join (`forFace`, `roomOnly`). `pollOnce` reads inboxes of non-room-only members only. Friends' Bots is `list_remote_bots` (Bots granted **to me**), minus reverse-friends of invites I created and minus face-room-only remotes. Accepting **Invite to this Bot** is the grant the other way; joining does not grant my talk-through Bot back. Join always offers **Talk through** (default Chief). `social/access` + `social/revoke-grant` (via `mcp-client.js` `listGrants` / `revokeGrant`, preferring `list_bot_grants` / `revoke_bot_grant`) list people as `Name (@home)` and revoke one person. Core `reach()` / `flybot.grants` are the unbypassable gate. **Face-to-face join (#83 / #89):** bringing my own Bot by number is an implicit room-scoped grant. If it has no identity, join registers it room-only; friends' `list_remote_bots`, invites, polling and `reach()` outside the room stay denied. When that Bot leaves its last face room or the room closes, the plugin revokes the room-only identity (`remove_home_member`) and drops room-scoped remotes from Friends' Bots. A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and drops `roomOnly`. -5. First start retries a taken home handle (`FlyBot`, `FlyBot2`, …) instead of failing for a YAML edit (#136). If every retry is still taken, Friends' Bots never shows the host's `Unknown FlyBot endpoint social/…` message: `apply` registers the social UI endpoints with a friendly `Social is not running: ` refusal and `flybot.warn` records the failure. The client also maps `bot/not-found` on `social/*` to that idle state. Friends' Bots shows the connected home handle with Copy. +5. First start retries a taken home handle (`FlyBot`, `FlyBot2`, …) instead of failing for a YAML edit (#136). If every retry is still taken, the social UI never shows the host's `Unknown FlyBot endpoint social/…` message: `apply` registers the social UI endpoints with a friendly `Social is not running: ` refusal and `flybot.warn` records the failure. The client also maps `bot/not-found` on `social/*` to that idle state. Sidebar **+** → **Copy my handle** copies the connected home handle (the toast repeats it). 6. Face-to-face join (#83, #92) is direct (no preview). The owner picks one or more local Bots in the Face-to-face group screen (sidebar **+**); each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social) and marks the owner's Bots with a tiny accent dot on the tile; **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). **Auto-send my Bot's room messages** is one click (`whoami` / MCP `set_auto_approve { auto_approve_face_rooms }`); if the server answers `confirmation_required`, the same click retries with `confirm_code` and the UI never asks for a 6-character code. REST `/v1/me` is not used with the connector token. A friend's Bot in the room member list and waiting grid uses the host Bot avatar (handle-seeded when Bot Social sends no image). New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. ### Inline approvals and compact Friends' Bots (#114, #115) -- Held Bot Social drafts (`social/status`, `social/decide-approval`) and matching local `flybot/approvals` holds show as a compact in-chat card (`flybot.session.dock`, max-width ~460px, left-aligned like a bubble — not full chat width): **Waiting for approval**, one `Name → Name` line (real names, never raw `@handle` / `bot_01…`), a 3-line clamped preview with **Show more**, and **Approve** / **Always approve** / **Reject** on one row for the owner. **Always approve** is owner-only: it approves this card and turns on the existing Auto-send for that pair (per-friend `social/auto-accept`, or face-room `social/face-auto`) so later messages on that pair are not held; it does not decide a different sender/recipient hold and is off by default. A toast says **Auto-send on for X → Y. Change it anytime in settings.** **Approve all** sits above the cards when several are waiting; anyone else sees **Waiting for the owner's approval** with no buttons. The owner does not get a waiting bubble in their 1:1; composer holds are labelled **You**. New friends start with **Auto-send** off. The owner's click is consent (`confirm_code` in the same `decide_approval` / `set_auto_approve` call). After a decision the card reads Approved or Rejected. The Friends' Bots "N to approve" block is a one-line jump list (chat name + truncated text, no explainer, no wrapping one word per line). Auto-send of face-room posts is a single compact checkbox (tooltip only) on the Face-to-face rooms header and the open room's overview — not on each pending row or room card. -- Each Face-to-face room in Friends' Bots is one compact row: name, a small member-avatar stack (`+N`), a muted open/closed/left/`10m` chip, click-to-open and **Leave** (no **Start** on the card). Closed, left, and ended matching rooms sit under **Past rooms** with **Open** only. With no live room the Face-to-face header and Auto-send are omitted. The list does not say "The number is no longer valid." 1:1 friend chats get the same wait badge as rooms (`flybot.chatApprovals.setProvider` → `bot.pendingCount`). The Friends' Bots header is title plus either the Bot count or the **N to approve** chip (not both) — no **+ Add** and no stacked approval list. **Add friend**, **Accept invite**, **Copy invite link**, and **Face-to-face group** are `flybot.new-chat.action` rows on the sidebar **+**. Add friend / Accept invite expand Friends' Bots and show mint-and-copy plus join / Accept / Decline. +- Held Bot Social drafts (`social/status`, `social/decide-approval`) and matching local `flybot/approvals` holds show as a compact in-chat card (`flybot.session.dock`, max-width ~460px, left-aligned like a bubble — not full chat width): **Waiting for approval**, one `Name → Name` line (real names, never raw `@handle` / `bot_01…`), a 3-line clamped preview with **Show more**, and **Approve** / **Always approve** / **Reject** on one row for the owner. **Always approve** is owner-only: it approves this card and turns on the existing Auto-send for that pair (per-friend `social/auto-accept`, or face-room `social/face-auto`) so later messages on that pair are not held; it does not decide a different sender/recipient hold and is off by default. A toast says **Auto-send on for X → Y. Change it anytime in settings.** **Approve all** sits above the cards when several are waiting; anyone else sees **Waiting for the owner's approval** with no buttons. The owner does not get a waiting bubble in their 1:1; composer holds are labelled **You**. New friends start with **Auto-send** off. The owner's click is consent (`confirm_code` in the same `decide_approval` / `set_auto_approve` call). After a decision the card reads Approved or Rejected. The social strip "N to approve" block is a one-line jump list (chat name + truncated text, no explainer, no wrapping one word per line). Auto-send of face-room posts is a single compact checkbox (tooltip only) on the open room's overview — not on each pending row or room card, and not duplicated on a Face-to-face rooms header. +- Live face-to-face rooms and friends' Bots sit in the core `TeamSidebar` list (globe / people mark), not a second **Friends' Bots** section. Closed, left, and ended matching rooms sit under **Past rooms** with **Open** only. The list does not say "The number is no longer valid." 1:1 friend chats get the same wait badge as rooms (`flybot.chatApprovals.setProvider` → `bot.pendingCount`). The social strip is **N to approve**, the + panels, and Past rooms — no **+ Add**, no per-friend groups, no Copy-handle row. **Add friend**, **Accept invite**, **Copy invite link**, **Copy my handle**, and **Face-to-face group** are `flybot.new-chat.action` rows on the sidebar **+**. Add friend / Accept invite open the join panel. Per-friend **Auto-send** and **Remove this Bot** live on that friend's Bot settings (`flybot.bot.details`). ### Face-to-face waiting grid, leave, and social + (#124, #125, #126) - Waiting UI (join dialog and `flybot.room.overview` while `candidate`) is a WeChat-like tile grid: avatar above the Bot name, owner on a second line (`data-flybot=face-owner`). **Leave** and **Enter chat** share one footer row. Join copy uses owner + Bot name (`Chief · BoxA` / `BoxA's Chief`), never `BoxA's BoxA` or generic `BoxA's Bot`. Ended joining says **Joining has ended**, not “matching window”. The group subtitle is live participant count (no “0 members · No admin”). -- **Leave** after the number is void (`bot_not_found` / room already gone) still drops the room off the active list onto **Past rooms**. Past rows are unique by `faceRoomId` and have one primary action (**Open**); live rows are click-to-open with **Leave** only (no Open/Start/Leave triple in the narrow Friends' Bots column). -- Sidebar **+** is a short action menu, not the To:/composer. Add friend has one-click **Copy invite link** (mint + clipboard). Friends' Bots keeps the **N to approve** badge and opens that chat; the stacked jump list stays out of the column. Held `face_room_message` drafts show in that face chat with readable names (`Chief → the room`), never raw `bot_01…`. +- **Leave** after the number is void (`bot_not_found` / room already gone) still drops the room off the active list onto **Past rooms**. Past rows are unique by `faceRoomId` and have one primary action (**Open**); live rows are the main-list RoomRow (people mark) with **Leave** on the open room overview. +- Sidebar **+** is a short action menu, not the To:/composer. Add friend has one-click **Copy invite link** (mint + clipboard). **Copy my handle** copies `@your.home` in the same click (toast repeats the handle). The social strip keeps the **N to approve** badge and opens that chat; the stacked jump list stays out of the column. Held `face_room_message` drafts show in that face chat with readable names (`Chief → the room`), never raw `bot_01…`. ### Face-room poll identity (#122) @@ -67,8 +67,8 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers ### Onboarding, grants, hide, and approval (#136–#140) -- **#136:** A taken `displayName` at onboard retries `Name2` … rather than refusing to start. Friends' Bots shows `@your.home` with Copy. -- **#137:** Owner-chosen Bot avatar/color is sent with MCP `update_bot_profile { bot_id?, display_name?, avatar?, color? }` (REST `PATCH /v1/bots/{id}/profile`) whenever a local Bot's look changes and on startup if not yet synced. `avatar` is a preset id, a single emoji, or `data:image/(png|jpeg|webp);base64` ≤ 64KiB; `color` is `#rrggbb`. Remote look is read from `list_remote_bots` / `list_friends` peer / `list_bot_grants` / face-room `members[]` (`avatar`, `avatar_kind`, `color`, `avatar_updated_at`) and shown in Friends' Bots, group member lists, waiting tiles, the picker, and chat headers. Cache key is `avatar_updated_at`; missing look falls back to the handle-seeded palette. Face-room events carry no look — `members[]` is the source. All fields are optional so an older Bot Social is unchanged. +- **#136:** A taken `displayName` at onboard retries `Name2` … rather than refusing to start. Sidebar **+** → **Copy my handle** copies `@your.home` (toast repeats it). +- **#137:** Owner-chosen Bot avatar/color is sent with MCP `update_bot_profile { bot_id?, display_name?, avatar?, color? }` (REST `PATCH /v1/bots/{id}/profile`) whenever a local Bot's look changes and on startup if not yet synced. `avatar` is a preset id, a single emoji, or `data:image/(png|jpeg|webp);base64` ≤ 64KiB; `color` is `#rrggbb`. Remote look is read from `list_remote_bots` / `list_friends` peer / `list_bot_grants` / face-room `members[]` (`avatar`, `avatar_kind`, `color`, `avatar_updated_at`) and shown in the main sidebar list, group member lists, waiting tiles, the picker, and chat headers. Cache key is `avatar_updated_at`; missing look falls back to the handle-seeded palette. Face-room events carry no look — `members[]` is the source. All fields are optional so an older Bot Social is unchanged. - **#138:** Accepting a Helper invite grants Helper only. The joiner picks a talk-through Bot (not shared). Success copy is `{person} shared {Bot} with you.` Manage access lists `SelfB1935 (@selfb1935.home)`. Core grants store a canonical `@handle` (never decorated `Chief@…`). On upgrade, remotes already on `list_remote_bots` are seeded into `inboundBots` once so reciprocal friends are not dropped. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only. - **#139:** **Remove this Bot** asks once (`Remove Helper? Bob can share it again.`) and calls `decline_bot_grant { grant_id, bot_id }` (`DELETE /v1/remote-bots/{grantId}`) so the owner's Manage access drops it. `grant_not_found` is treated as already removed. An older server (404 on the tool/endpoint) falls back to the local hide. Re-joining after a decline may return `regranted: true` on the existing friendship; live access still answers `409 relationship_exists`; a spent invite is `410 invite_unavailable`. Copy stays English (“You're already friends…”, “This invite was already used.”). Re-joining an existing friendship unhides only that invite's Bot/peer home. - **#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. @@ -77,12 +77,12 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers ### Past rooms, unique titles, poll, and + (#129–#133) - Expired matching (`candidate` whose `expiresAt` has passed) and a peer leaving the last remote seat move the hosted room to **Past rooms** on the next poll (`rememberPastFace`, status `closed` / `left`). The local room stays so **Open** still works; **Leave** is not shown on those cards. Face-room names are `Face-to-face · Alice, Bob · 482913` (people plus the 6-digit code, or `Oct 16 21:40` when there is no code) so two rooms with the same people stay distinct. The literal `Face-to-face room` is not a fallback. -- Inbox and face poll share one timer: `facePollMs` (default 4s) while any live face room exists, else `inboxPollMs` (default 15s). The open face overview keeps polling about every 3s after **Enter chat**, not only while matching. Friends' Bots still refreshes rooms about every 5s while that section is open. -- Sidebar **+** holds Face-to-face group, Add friend, Accept invite, and one-click Copy invite link. Friend requests is not a duplicate + row. Per-friend and face-room hold toggles both say **Auto-send** ("Don't ask each time."). Hold labels resolve the real Bot name (`Chief → the room`), never `bot_01…`. +- Inbox and face poll share one timer: `facePollMs` (default 4s) while any live face room exists, else `inboxPollMs` (default 15s). The open face overview keeps polling about every 3s after **Enter chat**, not only while matching. The social strip still refreshes past rooms about every 5s. +- Sidebar **+** holds Face-to-face group, Add friend, Accept invite, one-click Copy invite link, and Copy my handle. Friend requests is not a duplicate + row. Per-friend Auto-send is on that Bot's settings; face-room Auto-send is on the open room overview — both say **Auto-send** ("Don't ask each time."). Hold labels resolve the real Bot name (`Chief → the room`), never `bot_01…`. ### Face-to-face two-instance rooms (#117) -- Friends' Bots polls `social/face-rooms` with `poll: true` about every 5s while the section is open, so an opened room, a new member, and the compact rooms card appear without a reload. While matching, the status chip is a short `10m` / `joining` label so the name and **Start** fit; after **Enter chat** / start the hosted `room.face.status` is `open`. After **Leave**, only **Past rooms** remains — no empty "Face-to-face rooms" header or Auto-send. Waiting tiles use the server `label` (`Chief · BoxB`). A snapshot member that is new on this side still gets a join line when the join event is before the floored `after` cursor. Owner-typed lines in the open room fan out over `onFaceLine`: Bot Social face messages are bot-authored, so FlyBot sends a `FLYBOT-FACE-HUMAN` envelope as the owner's local room Bot (the owner's click/type is consent; a `pending_approval` is decided in that same step) and the other instance applies it as `authorKind: 'human'`. The displayed sender is the server-stamped `from` member, never the envelope `who`. Incoming peer/human bodies are stored fenced in the room log (#98). Join still does not grant the Bot to friends; Friends' Bots also refreshes `social/remote-bots` on its open timer. +- The social strip polls `social/face-rooms` with `poll: true` about every 5s, so a newly ended room appears under **Past rooms** without a reload. Live rooms are hosted in the core sidebar. While matching, the status chip is a short `10m` / `joining` label so the name and **Start** fit; after **Enter chat** / start the hosted `room.face.status` is `open`. After **Leave**, only **Past rooms** remains — no empty "Face-to-face rooms" header or Auto-send. Waiting tiles use the server `label` (`Chief · BoxB`). A snapshot member that is new on this side still gets a join line when the join event is before the floored `after` cursor. Owner-typed lines in the open room fan out over `onFaceLine`: Bot Social face messages are bot-authored, so FlyBot sends a `FLYBOT-FACE-HUMAN` envelope as the owner's local room Bot (the owner's click/type is consent; a `pending_approval` is decided in that same step) and the other instance applies it as `authorKind: 'human'`. The displayed sender is the server-stamped `from` member, never the envelope `who`. Incoming peer/human bodies are stored fenced in the room log (#98). Join still does not grant the Bot to friends. The host still publishes listed remotes into the main sidebar. ### Inbound shared groups diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index cf1f6007b..90725c33a 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -73,7 +73,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Add: '添加', Close: '关闭', Copy: '复制', -@@ -47,15 +47,41 @@ const ZH = { +@@ -47,15 +47,44 @@ const ZH = { 'Remove Main Bot role': '取消主 Bot', 'Bot settings': 'Bot 设置', 'Create group chat': '新建群聊', @@ -87,6 +87,9 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + "Paste a friend’s invite link or code.": '粘贴朋友的邀请链接或邀请码。', + 'Copy invite link': '复制邀请链接', + 'Copy a link a friend can paste in Add friend.': '复制链接,朋友在添加好友里粘贴即可。', ++ 'Copy my handle': '复制我的 handle', ++ 'Your Bot Social handle': '你的 Bot Social handle', ++ 'Face-to-face': '面对面', + 'This Bot has no model set up yet.': '这个 Bot 还没有配置模型。', + 'This Bot could not answer right now.': '这个 Bot 现在没法回答。', + 'Refreshing…': '正在刷新…', diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index bd0c9e5b8..28f8825de 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -79,7 +79,7 @@ diff --git a/src/client/bot-settings.js b/src/client/bot-settings.js isTile ? null : h(Card, { title: t('Sidebar') }, h(SwitchRow, { label: t('Pin'), hint: t('Kept above the other chats.'), on: bot.pinned === true, onChange: pinned => run(actions.setFlags(bot.id, { pinned })) }), isMainBot ? null : h(SwitchRow, { label: t('Hide from sidebar'), hint: t('Search still finds it.'), on: bot.hidden === true, onChange: hidden => run(actions.setFlags(bot.id, { hidden })) })), -+ h(FlyBotDetails, { bot, roster }), ++ h(FlyBotDetails, { bot, roster, actions }), h(Card, { title: t('Manage') }, h('button', { type: 'button', className: 'bt-set-row bt-bs-row', onClick: duplicate }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Duplicate')), h('span', { className: 'bt-set-hint' }, t('A copy with the same job and model, and fresh memory.'))), @@ -699,7 +699,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js bot.role ? h('span', { className: 'bt-chip' }, bot.role) : null)) } return h('button', { -@@ -78,15 +78,23 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio +@@ -78,15 +78,38 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio h(BotAvatar, { bot, size: 36, state, main }), pinned && !wide ? h(PinMark) : null, wide ? h('span', { className: 'bt-row-body' }, @@ -716,6 +716,21 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js + return h('span', { className: 'bt-row-remote', title: remote.gone ? `${remote.handle} · ${t('no longer shared')}` : remote.handle, 'aria-label': t('Friend\'s Bot') }, + h('svg', { width: 11, height: 11, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', 'aria-hidden': true }, + h('circle', { cx: 12, cy: 12, r: 9 }), h('path', { d: 'M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18' }))) ++} ++ ++function isPastFaceRoom(room) { ++ if (room?.origin !== 'face-to-face') return false ++ const status = room.face?.status ++ if (status === 'closed' || status === 'left') return true ++ const expires = room.face?.expiresAt ++ return status === 'candidate' && expires && Number.isFinite(Date.parse(expires)) && Date.parse(expires) <= Date.now() ++} ++ ++function FaceMark() { ++ return h('span', { className: 'bt-row-remote', title: t('Face-to-face'), 'aria-label': t('Face-to-face') }, ++ h('svg', { width: 11, height: 11, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', 'aria-hidden': true }, ++ h('circle', { cx: 9, cy: 8, r: 3 }), h('circle', { cx: 16, cy: 9, r: 2.5 }), ++ h('path', { d: 'M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0' }))) } function RoomRow({ room, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { @@ -727,14 +742,15 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js const active = useSessionRetainInfo(id, info => (info?.retainedBy?.mainView ?? 0) > 0) const unread = useSessionStatus(map => map.get(id)?.completionUnread === true) || (marked && !active) useMarkedUnread(id, active, marked, actions) -@@ -96,7 +96,9 @@ function RoomRow({ room, roster, actions, useSessions, useSessionStatus, useSess +@@ -96,7 +96,10 @@ function RoomRow({ room, roster, actions, useSessions, useSessionStatus, useSess const workingLine = first ? workLine(first.name, members[0].tool, members.length - 1) : null if (room.hidden && !active) return null const pinned = room.pinned === true - const label = [room.name, pinned ? t('Pinned') : '', members.length > 0 ? t('Working') : '', approval ? t('Waiting for your approval') : ''].filter(Boolean).join(', ') + const title = room.displayName || (room.sharedFrom ? sharedRoomTitle(room.sharedFrom.inviterName) : room.name) + const waiting = approval || (room.pendingCount ?? 0) > 0 -+ const label = [title, pinned ? t('Pinned') : '', members.length > 0 ? t('Working') : '', waiting ? t('Waiting for your approval') : ''].filter(Boolean).join(', ') ++ const face = room.origin === 'face-to-face' ++ const label = [title, face ? t('Face-to-face') : '', pinned ? t('Pinned') : '', members.length > 0 ? t('Working') : '', waiting ? t('Waiting for your approval') : ''].filter(Boolean).join(', ') return h('button', { type: 'button', className: 'bt-row', 'aria-label': label, 'aria-current': active ? 'page' : undefined, 'data-room-id': id, onClick: () => actions.openSession(id), @@ -743,7 +759,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js pinned && !wide ? h(PinMark) : null, wide ? h('span', { className: 'bt-row-body' }, - h('span', { className: 'bt-row-name' }, h('span', { className: 'bt-row-title' }, room.name), pinned ? h(PinMark) : null), -+ h('span', { className: 'bt-row-name' }, h('span', { className: 'bt-row-title' }, title), room.sharedFrom ? h('span', { className: 'bt-tag' }, t('Shared')) : null, pinned ? h(PinMark) : null), ++ h('span', { className: 'bt-row-name' }, h('span', { className: 'bt-row-title' }, title), face ? h(FaceMark) : room.sharedFrom ? h('span', { className: 'bt-tag' }, t('Shared')) : null, pinned ? h(PinMark) : null), workingLine ? h('span', { className: 'bt-row-preview' }, workingLine) : preview ? h('span', { className: 'bt-row-preview' }, preview) : null) : null, - wide && approval && !active ? h('span', { className: 'bt-unread bt-unread-wait', 'aria-hidden': true }) + wide && waiting && !active ? h('span', { className: 'bt-unread bt-unread-wait', 'aria-hidden': true }) @@ -782,25 +798,31 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js export function TeamSidebar(props) { const { wide, useRoster, useUi, useSessions, actions } = props const listRef = useRef(null) -@@ -249,7 +249,8 @@ export function TeamSidebar(props) { +@@ -249,14 +249,24 @@ export function TeamSidebar(props) { const order = useMemo(() => { const stamps = JSON.parse(updated) const entries = [ - ...roster.bots.filter(bot => bot.id !== roster.mainBotId).map(bot => ({ kind: 'bot', item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), -+ // Friends' Bots (remote chats) are listed by their plugin's section, not with the team. -+ ...roster.bots.filter(bot => bot.id !== roster.mainBotId && !bot.remote).map(bot => ({ kind: 'bot', item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), - ...roster.rooms.map(room => ({ kind: 'room', item: room, at: stamps[room.id] ?? room.createdAt })), +- ...roster.rooms.map(room => ({ kind: 'room', item: room, at: stamps[room.id] ?? room.createdAt })), ++ // Own Bots, friends' Bots, groups and face rooms share one list. A remote chat ++ // still uses BotRow's globe; gone / hidden / home-level remotes stay out. ++ ...roster.bots.filter(bot => bot.id !== roster.mainBotId && isListedRemoteChat(bot)).map(bot => ({ kind: 'bot', item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), ++ ...roster.rooms.filter(room => !isPastFaceRoom(room)).map(room => ({ kind: 'room', item: room, at: stamps[room.id] ?? room.createdAt })), // The Agent sorts by its most recent Session, falling back to when it was added. ...(roster.agents ?? []).map(agent => ({ kind: 'agent', item: agent, at: Math.max(0, ...agent.sessions.map(id => stamps[id] ?? 0)) || agent.createdAt })), -@@ -257,6 +257,11 @@ export function TeamSidebar(props) { - return entries.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || b.at - a.at) + ] +- return entries.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || b.at - a.at) ++ const foreign = entry => (entry.kind === 'bot' && entry.item.remote) || (entry.kind === 'room' && (entry.item.origin === 'face-to-face' || entry.item.sharedFrom)) ++ return entries.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || Number(foreign(a)) - Number(foreign(b)) || b.at - a.at) }, [roster, updated]) const main = roster.byId[roster.mainBotId] + // A plugin (the tree) may give the Bots a structure: the list then is one tree under the Main Bot. + const structure = useSidebarStructure() + const [collapsed, setCollapsed] = useState({}) + const toggle = id => setCollapsed(value => ({ ...value, [id]: !value[id] })) -+ const tree = structure && wide && main ? botTree([main, ...order.filter(entry => entry.kind === 'bot').map(entry => entry.item)], main.id, id => structure.parentOf(id)) : null ++ // Only own Bots belong to the tree; a friend's Bot stays in the ordered list below it. ++ const inTree = entry => entry.kind === 'bot' && !entry.item.remote ++ const tree = structure && wide && main ? botTree([main, ...order.filter(inTree).map(entry => entry.item)], main.id, id => structure.parentOf(id)) : null useListGlide(listRef, wide, order.map(entry => entry.item.id).join(',')) useLandOnMainBot(roster, useSessions, actions) const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide } @@ -812,7 +834,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js - order.map(entry => (entry.kind === 'bot' + tree ? h('div', { className: 'bt-tree', role: 'group', 'aria-label': t('Bot tree') }, tree.map(node => h(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions }))) + : main ? h(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : h('div', { className: 'bt-hint' }, t('Setting up your Main Bot…')), -+ order.filter(entry => !(tree && entry.kind === 'bot')).map(entry => (entry.kind === 'bot' ++ order.filter(entry => !(tree && inTree(entry))).map(entry => (entry.kind === 'bot' ? h(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === 'room' ? h(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index e3dbdfb76..9fbe11c0e 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -25,7 +25,7 @@ diff --git a/client.js b/client.js Add: "添加", Close: "关闭", Copy: "复制", -@@ -84,14 +84,40 @@ var ZH = { +@@ -84,14 +84,43 @@ var ZH = { "Remove Main Bot role": "取消主 Bot", "Bot settings": "Bot 设置", "Create group chat": "新建群聊", @@ -39,6 +39,9 @@ diff --git a/client.js b/client.js + "Paste a friend’s invite link or code.": "粘贴朋友的邀请链接或邀请码。", + "Copy invite link": "复制邀请链接", + "Copy a link a friend can paste in Add friend.": "复制链接,朋友在添加好友里粘贴即可。", ++ "Copy my handle": "复制我的 handle", ++ "Your Bot Social handle": "你的 Bot Social handle", ++ "Face-to-face": "面对面", + "This Bot has no model set up yet.": "这个 Bot 还没有配置模型。", + "This Bot could not answer right now.": "这个 Bot 现在没法回答。", + "Refreshing…": "正在刷新…", @@ -854,13 +857,13 @@ diff --git a/client.js b/client.js + (0, import_react2.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2 } }) + ))); +} -+function FlyBotDetails({ bot, roster }) { ++function FlyBotDetails({ bot, roster, actions }) { + const entries2 = useSlot("flybot.bot.details"); + if (entries2.length === 0) return null; + return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( + "div", + { key: entry.id, className: "bt-flybot-details", "data-flybot": entry.id }, -+ (0, import_react2.createElement)(Safe, { entry, props: { bot, roster, call: call2 } }) ++ (0, import_react2.createElement)(Safe, { entry, props: { bot, roster, actions, call: call2 } }) + ))); +} +var POLL_MS = 5e3; @@ -1209,17 +1212,10 @@ diff --git a/client.js b/client.js .bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} .bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} .bt-event>span{padding:0 2px} -@@ -2825,256 +2825,138 @@ function installCompiledStyles(text, tag) { - const names = /* @__PURE__ */ new Set(); - for (const sheet of document.styleSheets) { - if (sheet.ownerNode?.dataset?.dshBot) continue; -- let list = cache.get(sheet); -- if (!list) { -- list = sheetClassNames(sheet); -- cache.set(sheet, list); -- } -- for (const name of list) names.add(name); -- } +@@ -2832,249 +2832,131 @@ function installCompiledStyles(text, tag) { + } + for (const name of list) names.add(name); + } - const next = compileFragmentSelectors(text, names); - if (style.textContent !== next) style.textContent = next; - }; @@ -1368,13 +1364,6 @@ diff --git a/client.js b/client.js - } - const half = frame / 2; - return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; -+ let list = cache.get(sheet); -+ if (!list) { -+ list = sheetClassNames(sheet); -+ cache.set(sheet, list); -+ } -+ for (const name of list) names.add(name); -+ } + const next = compileFragmentSelectors(text, names); + if (style.textContent !== next) style.textContent = next; + }; @@ -2923,7 +2912,8 @@ diff --git a/client.js b/client.js + } + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react7.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react7.createElement)(RemoteMessage, { @@ -2936,8 +2926,7 @@ diff --git a/client.js b/client.js + msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, + useUi + }); - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); ++ } + return (0, import_react7.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); }), pending.map((id) => { @@ -3296,7 +3285,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4802,21 +4802,33 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio +@@ -4802,21 +4802,53 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio onClick: () => actions.openSession(id), onContextMenu }, @@ -3326,6 +3315,26 @@ diff --git a/client.js b/client.js + { width: 11, height: 11, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react9.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react9.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) ++ ) ++ ); ++} ++function isPastFaceRoom(room2) { ++ if (room2?.origin !== "face-to-face") return false; ++ const status = room2.face?.status; ++ if (status === "closed" || status === "left") return true; ++ const expires = room2.face?.expiresAt; ++ return status === "candidate" && expires && Number.isFinite(Date.parse(expires)) && Date.parse(expires) <= Date.now(); ++} ++function FaceMark() { ++ return (0, import_react9.createElement)( ++ "span", ++ { className: "bt-row-remote", title: t("Face-to-face"), "aria-label": t("Face-to-face") }, ++ (0, import_react9.createElement)( ++ "svg", ++ { width: 11, height: 11, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, ++ (0, import_react9.createElement)("circle", { cx: 9, cy: 8, r: 3 }), ++ (0, import_react9.createElement)("circle", { cx: 16, cy: 9, r: 2.5 }), ++ (0, import_react9.createElement)("path", { d: "M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0" }) + ) ); } @@ -3338,7 +3347,7 @@ diff --git a/client.js b/client.js const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; useMarkedUnread(id, active, marked, actions); -@@ -4826,8 +4826,10 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, +@@ -4826,8 +4826,11 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, const workingLine = first ? workLine(first.name, members[0].tool, members.length - 1) : null; if (room2.hidden && !active) return null; const pinned = room2.pinned === true; @@ -3346,7 +3355,8 @@ diff --git a/client.js b/client.js - return (0, import_react8.createElement)( + const title = room2.displayName || (room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2.name); + const waiting = approval || (room2.pendingCount ?? 0) > 0; -+ const label = [title, pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", waiting ? t("Waiting for your approval") : ""].filter(Boolean).join(", "); ++ const face = room2.origin === "face-to-face"; ++ const label = [title, face ? t("Face-to-face") : "", pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", waiting ? t("Waiting for your approval") : ""].filter(Boolean).join(", "); + return (0, import_react9.createElement)( "button", { @@ -3365,7 +3375,7 @@ diff --git a/client.js b/client.js { className: "bt-row-body" }, - (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, room2.name), pinned ? (0, import_react8.createElement)(PinMark) : null), - workingLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null -+ (0, import_react9.createElement)("span", { className: "bt-row-name" }, (0, import_react9.createElement)("span", { className: "bt-row-title" }, title), room2.sharedFrom ? (0, import_react9.createElement)("span", { className: "bt-tag" }, t("Shared")) : null, pinned ? (0, import_react9.createElement)(PinMark) : null), ++ (0, import_react9.createElement)("span", { className: "bt-row-name" }, (0, import_react9.createElement)("span", { className: "bt-row-title" }, title), face ? (0, import_react9.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react9.createElement)("span", { className: "bt-tag" }, t("Shared")) : null, pinned ? (0, import_react9.createElement)(PinMark) : null), + workingLine ? (0, import_react9.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react9.createElement)("span", { className: "bt-row-preview" }, preview) : null ) : null, - wide && approval && !active ? (0, import_react8.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null @@ -3525,7 +3535,7 @@ diff --git a/client.js b/client.js const roster = useRoster((value) => value); const markedUnread = useUi((value) => value.unread); props.useRegistry((value) => value); -@@ -5025,10 +5025,11 @@ function TeamSidebar(props) { +@@ -5025,17 +5025,25 @@ function TeamSidebar(props) { for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; return JSON.stringify(stamps); }); @@ -3534,19 +3544,24 @@ diff --git a/client.js b/client.js const stamps = JSON.parse(updated); const entries2 = [ - ...roster.bots.filter((bot) => bot.id !== roster.mainBotId).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), -+ // Friends' Bots (remote chats) are listed by their plugin's section, not with the team. -+ ...roster.bots.filter((bot) => bot.id !== roster.mainBotId && !bot.remote).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), - ...roster.rooms.map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), +- ...roster.rooms.map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), ++ // Own Bots, friends' Bots, groups and face rooms share one list. A remote chat ++ // still uses BotRow's globe; gone / hidden / home-level remotes stay out. ++ ...roster.bots.filter((bot) => bot.id !== roster.mainBotId && isListedRemoteChat(bot)).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), ++ ...roster.rooms.filter((room2) => !isPastFaceRoom(room2)).map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), // The Agent sorts by its most recent Session, falling back to when it was added. ...(roster.agents ?? []).map((agent) => ({ kind: "agent", item: agent, at: Math.max(0, ...agent.sessions.map((id) => stamps[id] ?? 0)) || agent.createdAt })) -@@ -5036,6 +5036,10 @@ function TeamSidebar(props) { - return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || b.at - a.at); + ]; +- return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || b.at - a.at); ++ const foreign = (entry) => entry.kind === "bot" && entry.item.remote || entry.kind === "room" && (entry.item.origin === "face-to-face" || entry.item.sharedFrom); ++ return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || Number(foreign(a)) - Number(foreign(b)) || b.at - a.at); }, [roster, updated]); const main = roster.byId[roster.mainBotId]; + const structure = useSidebarStructure(); + const [collapsed, setCollapsed] = (0, import_react9.useState)({}); + const toggle = (id) => setCollapsed((value) => ({ ...value, [id]: !value[id] })); -+ const tree = structure && wide && main ? botTree([main, ...order.filter((entry) => entry.kind === "bot").map((entry) => entry.item)], main.id, (id) => structure.parentOf(id)) : null; ++ const inTree = (entry) => entry.kind === "bot" && !entry.item.remote; ++ const tree = structure && wide && main ? botTree([main, ...order.filter(inTree).map((entry) => entry.item)], main.id, (id) => structure.parentOf(id)) : null; useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); useLandOnMainBot(roster, useSessions, actions); const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; @@ -3589,7 +3604,7 @@ diff --git a/client.js b/client.js - main ? (0, import_react8.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react8.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), - order.map((entry) => entry.kind === "bot" ? (0, import_react8.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react8.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react8.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })) + tree ? (0, import_react9.createElement)("div", { className: "bt-tree", role: "group", "aria-label": t("Bot tree") }, tree.map((node) => (0, import_react9.createElement)(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions }))) : main ? (0, import_react9.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react9.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), -+ order.filter((entry) => !(tree && entry.kind === "bot")).map((entry) => entry.kind === "bot" ? (0, import_react9.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react9.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react9.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })), ++ order.filter((entry) => !(tree && inTree(entry))).map((entry) => entry.kind === "bot" ? (0, import_react9.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react9.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react9.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })), + wide ? (0, import_react9.createElement)(FlyBotSections, { roster, actions, renderBot: (bot) => (0, import_react9.createElement)(BotRow, { key: bot.id, ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }) }) : null ) ); @@ -6168,7 +6183,7 @@ diff --git a/client.js b/client.js + isMainBot ? null : (0, import_react19.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) }) ), - (0, import_react18.createElement)( -+ (0, import_react19.createElement)(FlyBotDetails, { bot, roster }), ++ (0, import_react19.createElement)(FlyBotDetails, { bot, roster, actions }), + (0, import_react19.createElement)( Card, { title: t("Manage") }, diff --git a/patches/ds-bot/files/src/client/flybot-slots.js b/patches/ds-bot/files/src/client/flybot-slots.js index 366b5315e..7efa682b4 100644 --- a/patches/ds-bot/files/src/client/flybot-slots.js +++ b/patches/ds-bot/files/src/client/flybot-slots.js @@ -105,11 +105,11 @@ export function FlyBotRoomOverview({ room, roster, actions }) { h(Safe, { entry, props: { room, roster, actions, call } })))) } -export function FlyBotDetails({ bot, roster }) { +export function FlyBotDetails({ bot, roster, actions }) { const entries = useSlot('flybot.bot.details') if (entries.length === 0) return null return h(Fragment, null, entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-details', 'data-flybot': entry.id }, - h(Safe, { entry, props: { bot, roster, call } })))) + h(Safe, { entry, props: { bot, roster, actions, call } })))) } const POLL_MS = 5000 diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 4a13d8368..63eb44ac9 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -791,7 +791,7 @@ export function install(rt) { return `${name}: not sent${shown ? ` — ${shown}` : ''}` } if (action === 'hold') return `${name}: held for your approval${why ? ` — ${why}` : ''}` - return `${name}: sent — its replies appear in its own chat under Friends' Bots, not in this group` + return `${name}: sent — its replies appear in its own chat, not in this group` }) try { const agent = await rt.resolveAgent(sessionId) diff --git a/patches/ds-bot/files/test/flybot.test.js b/patches/ds-bot/files/test/flybot.test.js index 56e768232..ae0c13c97 100644 --- a/patches/ds-bot/files/test/flybot.test.js +++ b/patches/ds-bot/files/test/flybot.test.js @@ -378,16 +378,26 @@ test('mentions in a group (#80, #81): the room gets a receipt — sent points to fb.transports.add('bot-social', { send: async msg => { out.push(msg); return { exchangeId: `r${out.length}` } } }) fb.scope('social').remote.publish([{ handle: '@helper.friend.home', name: '助手', home: '@friend.home' }]) let seq = 0 - const say = async text => { + const receipts = () => (agents.get('room-1')?.calls ?? []).map(([, m]) => m.content.map(b => b.text).join('\n')) + // Hold persists the approval (load/save + taskHeld) and can race remote-chat open + // after publish; wait for the receipt instead of a fixed 30 ms. + const until = async (check) => { + const end = Date.now() + 2000 + while (!check()) { + if (Date.now() > end) throw new Error('until: the condition did not hold within 2 s') + await delay(10) + } + } + const say = async (text, n) => { ctx.emitArgs('session/event', { id: 'room-1' }, { type: 'user/message', seq: ++seq, data: { source: { kind: 'user' }, content: [{ type: 'text', text }] } }) - await delay(30) + if (n === undefined) await delay(30) + else await until(() => receipts().length >= n) } - const receipts = () => (agents.get('room-1')?.calls ?? []).map(([, m]) => m.content.map(b => b.text).join('\n')) - await say('@helper.friend.home 你好') + await say('@helper.friend.home 你好', 1) assert.equal(out.length, 1) // One receipt, where the reply lands (#80): a friend's Bot's answer never re-enters the group. assert.equal(receipts().length, 1) - assert.match(receipts()[0], /助手: sent — its replies appear in its own chat under Friends' Bots, not in this group/) + assert.match(receipts()[0], /助手: sent — its replies appear in its own chat, not in this group/) const posted = agents.get('room-1').calls[0][1] assert.equal(posted.source.kind, 'bot') assert.equal(posted.source.role, 'mention') @@ -396,13 +406,14 @@ test('mentions in a group (#80, #81): the room gets a receipt — sent points to // A plain message gets no receipt. await say('no mentions here') assert.equal(receipts().length, 1) - // A hold (#81) says what is waiting and where. + // A hold (#81) says what is waiting and where. Friend is not a room participant, so + // #143/#144's in-room skip does not apply: the 1:1 send still runs and the room hears why. fb.bot.send.use({ name: 'deny-some', handle: m => (m.target?.remote === true && /no/.test(m.text) ? { action: 'deny', reason: 'no friendship' } : undefined) }) fb.bot.send.use({ name: 'hold-remote', handle: m => (m.target?.remote === true ? { action: 'hold', reason: 'check with the user' } : undefined) }) - await say('@helper.friend.home say no') + await say('@helper.friend.home say no', 2) assert.equal(out.length, 1) assert.match(receipts()[1], /助手: not sent — no friendship/) - await say('@helper.friend.home say yes') + await say('@helper.friend.home say yes', 3) assert.equal(out.length, 1) assert.match(receipts()[2], /助手: held for your approval — check with the user/) // A mention from a Bot's own chat stays as it was: no receipt outside a group. diff --git a/patches/ds-bot/files/test/new-chat.test.js b/patches/ds-bot/files/test/new-chat.test.js index 101ad628d..efcbeb41a 100644 --- a/patches/ds-bot/files/test/new-chat.test.js +++ b/patches/ds-bot/files/test/new-chat.test.js @@ -56,7 +56,15 @@ test('the sidebar + menu wires plugin actions in after Create group chat', () => const slots = readFileSync(new URL('../src/client/flybot-slots.js', import.meta.url), 'utf8') assert.match(slots, /isListedRemoteChat/) const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') - assert.match(sidebar, /isListedRemoteChat/) + assert.match(sidebar, /isListedRemoteChat\(bot\)/) + assert.match(sidebar, /!isPastFaceRoom\(room\)/) + assert.match(sidebar, /Number\(foreign\(a\)\) - Number\(foreign\(b\)\)/) + // With @flybot/tree on, a friend's Bot is not a tree node under Main; it stays in the flat list. + assert.match(sidebar, /const inTree = entry => entry\.kind === 'bot' && !entry\.item\.remote/) + assert.match(sidebar, /botTree\(\[main, \.\.\.order\.filter\(inTree\)/) + assert.match(sidebar, /order\.filter\(entry => !\(tree && inTree\(entry\)\)\)/) + assert.equal(DICTIONARY.zh['Copy my handle'], '复制我的 handle') + assert.equal(DICTIONARY.zh['Face-to-face'], '面对面') assert.equal(DICTIONARY.zh['Face-to-face group'], '面对面建群') assert.equal(DICTIONARY.zh['Create group chat'], '新建群聊') assert.match(DICTIONARY.zh['Meet the people next to you. Pick your Bots and type the same 6-digit number.'], /身边/) diff --git a/plugins/social/README.md b/plugins/social/README.md index ad16d699c..f02e1fbc9 100644 --- a/plugins/social/README.md +++ b/plugins/social/README.md @@ -7,8 +7,8 @@ browser: `client.js`. Design: see "Social design: homes and members" in the root 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). If `@.home` is taken, onboarding retries `Name2`, `Name3`, … so a second - install can start without editing YAML. Friends' Bots shows the connected handle with - Copy. If start still fails, it shows **Social is not running:** with the reason instead + install can start without editing YAML. Sidebar **+** → **Copy my handle** copies the + connected handle. If start still fails, it shows **Social is not running:** with the reason instead of the host's `Unknown FlyBot endpoint` message. The same failure is written to Settings → Feedback. - **Members**: a local Bot becomes `@.` (`add_home_member`) when it is invited, when it joins an invite, or at face-to-face join, stored as field `social.member`. There is no published @@ -20,78 +20,9 @@ browser: `client.js`. Design: see "Social design: homes and members" in the root When you leave the last face room or the room closes, the plugin revokes it (`remove_home_member`). A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and drops `roomOnly`. **Manage access** on the Bot lists who has it and **Revoke**. -- **Remote roster**: `list_remote_bots` → friends' Bots grouped by home; sidebar section - `flybot.sidebar.section#social` with a remote badge, `@` rows via - `flybot.composer.mention#social`. Remote Bots never enter `bot.list()`. Each row uses - the published `avatar` / `color` when the server sends them, else a handle-seeded look. +- **Remote roster**: `list_remote_bots` → friends' Bots published into the main Bot list + (globe mark). Each row uses the published `avatar` / `color` when the server sends them, + else a handle-seeded look. Sidebar section `flybot.sidebar.section#social` keeps **N to approve**, + + panels, and **Past rooms**. `@` rows via `flybot.composer.mention#social`. Remote Bots never enter `bot.list()`. **Remove this Bot** declines that grant (`decline_bot_grant`) so it leaves the owner's Manage access; an older server hides it locally. -- **Send middleware `social`**: a remote target must be a Bot of a friend home; approval is - per friend home (`approval: { default: 'hold', homes: { '@bob.home': 'auto' } }`), - overridden per local Bot by field `social.autoApprove[home]` (UI endpoint - `flybot/social/auto-approve`). Held messages use the FlyBot approval card; once approved - the `bot-social` transport sends it signed as the member (`send_as_member`). -- **Inbound**: each member's inbox is polled; messages reach that Bot's chat as untrusted text (no owner approval). -- **Outbound hold (Bot Social)**: a draft the server keeps as `pending_approval` is listed on `social/status` (`pendingApprovals`, `approvals`). The owner **Approve** / **Reject**s it on the in-chat card (`flybot.session.dock`); others see “Waiting for the owner's approval” (the owner does not get a waiting bubble in their 1:1). Friends' Bots shows a badge and a one-line jump list (chat name + truncated text). The owner's click calls `decide_approval` (confirm code used in the same step). This is separate from FlyBot's local approval card. **Auto-send** on a friendship is off until the owner turns it on. Room messages (`face_room_message`) use the same queue; face-room **Auto-send** (`social/face-auto` → MCP `whoami` / `set_auto_approve { auto_approve_face_rooms }`) is one compact checkbox (tooltip) on the Face-to-face rooms header. Connector tokens cannot `GET`/`PATCH /v1/me`. -- **Face-to-face rooms**: people next to each other join one group by typing the same 6-digit number. Friends' Bots lists each room as one row (name, avatar stack, open/closed chip, Open / Leave); closed and left rooms fold under **Past rooms**. See Quick Start below. -- **Tools** (every local Bot; an ungranted Bot's send is refused by core `reach()`): `list_friend_bots`, `message_friend_bot`. -- **Legacy**: with `token` + `botId` and no home, the per-bot identity path - (`start_conversation` / `send_message`) still works. - -Server side (Bot Social, local only): `/v1/onboard/flybot`, `/v1/homes/:id/members`, -`/v1/homes/:id/remote-bots`, `/v1/homes/:id/send`, grant REST (`GET /v1/relationships/{id}/grants`, -`DELETE /v1/grants/{id}`), `PATCH /v1/bots/{id}/profile`, `DELETE /v1/remote-bots/{grantId}`, -and owner MCP tools `add_home_member`, -`remove_home_member`, `list_home_members`, `list_remote_bots`, `send_as_member`, -`create_invite`, `join_invite`, `list_grants`, `revoke_grant`, `update_bot_profile`, -`decline_bot_grant`. Grant/invite/look calls are isolated -in `src/mcp-client.js` (`createSocialApi`) so a later server can add tools without touching -the plugin's grant bookkeeping. Look fields and `decline_bot_grant` are optional: an older -server is unchanged (local hide; handle-seeded avatars). - -Tests (`test/social.test.js`) use a mocked server. - -## Files - -- **Bots:** `message_friend_bot` takes `files` (absolute paths). Each file is at most 20 MB (`config.maxFileBytes`), with at most 5 per message. -- **Owner:** each remote member in the sidebar has a composer with a file picker. The files wait in a private outbox (mode 600). -- **Approval:** the approval card lists every file's name and size. Nothing is uploaded until you approve. Then the bytes are read again, their SHA-256 is checked against what was approved, and they are uploaded and sent as signed refs. -- **Incoming:** files show as chips on the remote Bot's bubble; a click downloads the file after the hash check. The receiving Bot sees the file names inside the untrusted fence. `get_friend_file(id)` saves the file to `/flybot-social-files/inbox` (mode 600) and returns text content in an `` fence. -- **Server rules:** Bot Social enforces types, size, expiry (7 days) and recipient-only download. - -## Quick Start — face-to-face join - -People in the same place (or on a call) put their Bots in one room by each typing the same 6-digit number. There is no host and no friend request. - -1. Click **+** at the top of the sidebar (next to Search) and choose **Face-to-face group**. -2. Choose which of your Bots to bring (tap tiles; default: the Main Bot). Each Bot shows the same avatar as in the sidebar. Type the same six digits as the people next to you. Click **Join**. -3. You join at once — there is no preview. Several selected Bots join the same room (one join per Bot). If one of them fails, the others still join and the failure is shown. A waiting room is a wrapping grid of who is in (each Bot's avatar on top and name below; yours with a tiny accent dot). The countdown stays at the top; **Leave** and **Enter chat** share the footer. If a Bot has no Bot Social identity yet, FlyBot registers it for this room only, then joins — friends still cannot reach it unless you invite them to it. **Invite to this Bot** / **Manage access** (Bot settings) are how you grant and revoke. -4. **Enter chat** starts the group and opens it (voids the number). If someone else starts first, this screen opens the chat for you. Joining stays open for 10 minutes; when it ends or the room starts, the number is invalid. -5. **Leave** removes your Bots in one click. Anyone can leave; if the last member leaves, the room closes and the number is free again. - -Bot replies in that room wait for your approval the same way friend replies do (`N to approve` / Waiting for your approval). Approve or Reject them there; they are not sent until you approve. The room header has **Auto-send my Bot's room messages** (also **Turn on auto-send** / **Approve and always auto-send in this room** on a waiting draft): one click, and your Bots post in rooms without asking each time. The same toggle turns it off. The setting is for this owner on Bot Social (`auto_approve_face_rooms`), not one room only. - -A failed number never shows who is in a room. Too many attempts ask you to wait (`retry_after`). A full room (8 Bots) or a number that already started is refused in plain English. - -## Add friends (sidebar) - -Invites are **per Bot**; there is no whole-home invite. A friend who joins an invite made for one of your Bots can talk to that Bot only; your other Bots stay hidden unless you invite them too (server: friendships scoped to the invite's target, Bot Social `requester_scope`/`target_scope`; the roster lists only what `list_remote_bots` returns). - -The **Friends' Bots** section (collapsible header with a Bot count, ds-bot `--bt-*` tokens) lists friends' Bots by home, with an empty state. It paints a cached roster immediately and shows **Refreshing…** while it talks to Bot Social. The header is title, count, and the **N to approve** chip — there is no **+ Add** on it. **Face-to-face group**, **Add friend**, and **Friend requests** are on the sidebar **+** menu (next to Create group chat, slot `flybot.new-chat.action`): - -- **Face-to-face group:** pick Bots, 6-digit number, waiting room, Enter chat. -- **Add friend** / **Friend requests:** expand Friends' Bots and show the join panel. **Copy invite link** mints a one-use invite for the selected Bot and copies it. **Paste a friend's invite link or code** → **Join** sends a friend request from one of your Bots (**Join as**, default: the Main Bot). The friendship is between the invited Bot and that Bot only: your friend sees and reaches just it, and only it can message their Bot (the roster's `via`; other local Bots are refused before anything leaves). If the server also needs your own approval, the click counts as that approval. Incoming requests have **Accept** / **Decline**; requests you sent show under *Waiting for them to accept*. -- **Invite a friend to one of your Bots:** **Copy invite link** on Add friend, or right-click the Bot (Bot settings **Invite to this Bot**) and **Create invite link**. It works once and lasts 7 days. **Copy link** copies only the bare URL; **Copy invite with instructions** copies a short message naming the Bot ("Add my bot @chief. (Chief) from my FlyBot home . In FlyBot: open the + menu, choose Add friend, paste this link and click Join. Using a Grok bot? Send it this link and say 'join this FlyBot invite'. Link: (single use, expires )"). Each clipboard write happens inside the click. - -Each Bot's settings also has **Invite to this Bot** (slot `flybot.bot.details`). - -### API for other plugins - -- Host endpoint `social/create-invite` (alias `social.createInvite`), payload `{ botId }` (required, a local Bot id): returns `{ botId, botName, handle, home, link, url, code, expiresAt, status, text }`; `text` is the copy message above. Browser: `flybot.call('social.createInvite', { botId })` or `ctx.flybot.social.createInvite(botId)`. -- `ctx.flybot.social.canInvite(botId)` (also `globalThis.__flybot__.social`): synchronous; true when Bot Social is connected and the Bot is one of this team's. `ctx.flybot.social` is absent when the social plugin is not installed. -- Host endpoint `social/status`: `{ connected, home, bots: [{ id, name, isMain, handle }], pendingApprovals, approvals, autoApproveFaceRooms }`. `approvals` is the outbound drafts Bot Social is holding; `pendingApprovals` is their count; `autoApproveFaceRooms` is the owner's Bot Social `auto_approve_face_rooms` flag. -- `social/approvals` (read): `{ approvals }` — same queue, always freshly fetched. -- `social/decide-approval` (owner click): `{ approvalId, decision: 'approve' | 'reject' }` → `{ approvalId, status }`. -- `social/face-auto` (owner click): `{ on: true | false }` → `{ autoApproveFaceRooms }` (MCP `set_auto_approve`; omit `on` to read via `whoami`). If Bot Social answers `confirmation_required`, the same click retries with `confirm_code`; the UI never asks for a 6-character code. - -These are owner UI actions only and are never model tools. The server's one-time confirm code is requested and used in the same click. diff --git a/plugins/social/client.js b/plugins/social/client.js index 72c6fddcf..e73a90c39 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -890,16 +890,11 @@ window.__ModuleLoader__.load({ } function FaceRooms({ actions, enabled = true, roster, remoteChats } = {}) { const [rooms, setRooms] = useState([]) - const [autoSend, setAutoSend] = useState(false) - const [autoBusy, setAutoBusy] = useState(false) - const [autoError, setAutoError] = useState('') const [pastOpen, setPastOpen] = useState(false) const unique = uniqueFaceRooms(rooms) - const live = unique.filter(room => !isPastFace(room)) const past = unique.filter(isPastFace) const load = (poll = true) => call('social/face-rooms', { poll }).then(value => { setRooms(Array.isArray(value?.rooms) ? value.rooms : []) - if (typeof value?.autoApproveFaceRooms === 'boolean') setAutoSend(value.autoApproveFaceRooms) }, () => {}) useEffect(() => { if (!enabled) return undefined @@ -913,31 +908,17 @@ window.__ModuleLoader__.load({ timer?.unref?.() return () => clearInterval(timer) }, [enabled]) - const toggle = async (next) => { - setAutoBusy(true) - setAutoError('') - try { - const result = await call('social/face-auto', { on: next }) - setAutoSend(result?.autoApproveFaceRooms === true) - } catch (e) { setAutoError(errorText(e)) } - finally { setAutoBusy(false) } - } - if (!unique.length) return null + if (!past.length) return null const card = (room, folded) => h(FaceRoomCard, { key: room.faceRoomId || room.roomId, room, past: folded, actions, onChange: () => load(true), roster, remoteChats, }) - const pastFold = past.length ? h('div', { className: 'fs-past', 'data-flybot': 'face-past' }, - h('button', { type: 'button', className: 'fs-past-head', 'aria-expanded': pastOpen, onClick: () => setPastOpen(open => !open) }, - h('span', { className: 'fs-chev', 'aria-hidden': true, 'data-closed': pastOpen ? undefined : true }, '▾'), - 'Past rooms', - h('span', { className: 'fs-count' }, String(past.length))), - pastOpen ? past.map(room => card(room, true)) : null) : null return h('div', { 'data-flybot': 'face-rooms' }, - live.length ? h('div', { className: 'fs-label fs-face-head' }, - 'Face-to-face rooms', - h(FaceAutoSend, { on: autoSend, busy: autoBusy, onChange: toggle, error: autoError })) : null, - live.map(room => card(room, false)), - pastFold) + h('div', { className: 'fs-past', 'data-flybot': 'face-past' }, + h('button', { type: 'button', className: 'fs-past-head', 'aria-expanded': pastOpen, onClick: () => setPastOpen(open => !open) }, + h('span', { className: 'fs-chev', 'aria-hidden': true, 'data-closed': pastOpen ? undefined : true }, '▾'), + 'Past rooms', + h('span', { className: 'fs-count' }, String(past.length))), + pastOpen ? past.map(room => card(room, true)) : null)) } function FaceRoomOverview({ room, roster, actions } = {}) { @@ -1015,19 +996,6 @@ window.__ModuleLoader__.load({ note ? h('div', { className: 'fs-status', 'data-tone': note.tone }, note.text) : null) } - const ROSTER_CACHE_KEY = 'flybot-social-remote-bots' - function readCachedRoster() { - try { - const raw = globalThis.sessionStorage?.getItem?.(ROSTER_CACHE_KEY) - if (!raw) return null - const parsed = JSON.parse(raw) - return parsed && Array.isArray(parsed.homes) ? parsed : null - } catch { return null } - } - function writeCachedRoster(value) { - try { globalThis.sessionStorage?.setItem?.(ROSTER_CACHE_KEY, JSON.stringify({ homes: value?.homes ?? [] })) } catch {} - } - function makeOpener() { let handler = null let pending = false @@ -1048,91 +1016,31 @@ window.__ModuleLoader__.load({ const openFaceToFace = () => facePanel.open() const openAddFriend = () => addFriendPanel.open() - function FriendsBots({ renderBot, remoteChats, actions, roster } = {}) { + function FriendsBots({ remoteChats, actions, roster } = {}) { useStyles() - const [data, setData] = useState(() => readCachedRoster()) const [error, setError] = useState('') - const [open, setOpen] = useState(true) const [adding, setAdding] = useState(false) - const [closed, setClosed] = useState({}) - const [auto, setAuto] = useState({}) - const [saving, setSaving] = useState('') const [pending, setPending] = useState([]) - const [hiding, setHiding] = useState(() => new Set()) const [faceOpen, setFaceOpen] = useState(false) - const [refreshing, setRefreshing] = useState(false) - const [homeHandle, setHomeHandle] = useState('') - const openRef = useRef(open) - openRef.current = open - const load = () => call('social/remote-bots', { fresh: true }).then(value => { - setData(value) - writeCachedRoster(value) + const loadApprovals = () => call('social/status', {}).then(value => { + setPending(Array.isArray(value?.approvals) ? value.approvals : []) setError('') }, e => setError(errorText(e))) - const loadSettings = () => call('social/settings', {}).then(value => setAuto(value?.autoAccept ?? {}), () => {}) - const loadApprovals = () => call('social/status', {}).then(value => { - const items = Array.isArray(value?.approvals) ? value.approvals : [] - setPending(items) - if (value?.home) setHomeHandle(value.home) - if (items.length) setOpen(true) - }, () => {}) - const loadAll = () => { - setRefreshing(true) - return Promise.all([load(), loadSettings(), loadApprovals()]).finally(() => setRefreshing(false)) - } useEffect(() => { - loadAll() + loadApprovals() if (typeof setInterval !== 'function') return undefined const tick = () => { - if (!openRef.current) return if (typeof document !== 'undefined' && document.hidden) return loadApprovals() - load() } const timer = setInterval(tick, 15_000) timer?.unref?.() return () => clearInterval(timer) }, []) - useEffect(() => facePanel.bind(() => { setFaceOpen(true); setOpen(true) }), []) - useEffect(() => addFriendPanel.bind(() => { setAdding(true); setOpen(true) }), []) - const homes = data?.homes ?? [] - const count = homes.reduce((n, entry) => n + entry.bots.length, 0) + useEffect(() => facePanel.bind(() => setFaceOpen(true)), []) + useEffect(() => addFriendPanel.bind(() => setAdding(true)), []) const chats = remoteChats ?? [] - const unavailable = error.startsWith('Social is not running:') - const toggleAuto = async (home, on) => { - setSaving(home) - setAuto(value => ({ ...value, [home]: on })) - try { const r = await call('social/auto-accept', { home, on }); setAuto(value => ({ ...value, [home]: r.autoAccept })) } catch (e) { setError(errorText(e)); setAuto(value => ({ ...value, [home]: !on })) } finally { setSaving('') } - } - const copyHome = () => { - const clip = typeof navigator !== 'undefined' ? navigator.clipboard : undefined - if (homeHandle && clip?.writeText) void clip.writeText(homeHandle) - } - const markHiding = (handle, on) => { - setHiding(current => { - const next = new Set(current) - if (on) next.add(handle) - else next.delete(handle) - return next - }) - } - const removeBot = async (bot) => { - const home = (data?.homes ?? []).find(entry => (entry.bots ?? []).some(item => item.handle === bot.handle)) - const who = home?.name || home?.owner || home?.home || 'Your friend' - const ask = typeof globalThis.confirm === 'function' ? globalThis.confirm : undefined - if (ask && !ask(`Remove ${bot.name}? ${who} can share it again.`)) return - markHiding(bot.handle, true) - try { - const result = await call('social/hide-remote', { handle: bot.handle }) - if (result?.homes) setData(result) - if (typeof actions?.updateRoom === 'function') { - void Promise.all((actions.roster?.rooms ?? []).flatMap(room => { - const hit = (room.participants ?? []).find(p => p.remote && p.handle === bot.handle && p.leftSeq === undefined) - return hit ? [actions.updateRoom(room.id, { removeParticipants: [{ kind: 'bot', id: hit.handle || hit.id, remote: true }] })] : [] - })).catch(e => setError(errorText(e))) - } - } catch (e) { setError(errorText(e)) } finally { markHiding(bot.handle, false) } - } + const unavailable = String(error).startsWith('Social is not running:') const openItem = (item) => { if (!item || typeof actions?.openSession !== 'function') return const chat = chats.find(entry => entry.remote?.handle === item.to) @@ -1146,46 +1054,20 @@ window.__ModuleLoader__.load({ else if (item.roomId) actions.openSession(item.roomId) else if (item.sessionId) actions.openSession(item.sessionId) } - const openPending = () => { - openItem(pending[0]) - setOpen(true) + if (unavailable) { + return h('section', { className: 'fs bt-social', 'aria-label': 'Social' }, + h('div', { className: 'fs-empty', 'data-flybot': 'social-unavailable' }, error)) } - return h('section', { className: 'fs bt-social', 'aria-label': 'Friends\' Bots' }, - h('div', { className: 'fs-head' }, - h('button', { type: 'button', className: 'fs-head-main', 'aria-expanded': open, onClick: () => setOpen(!open) }, - h('span', { className: 'fs-chev', 'aria-hidden': true }, '▾'), - h('span', { className: 'fs-title' }, 'Friends\' Bots'), - data && !pending.length ? h('span', { className: 'fs-count', title: `${count} ${count === 1 ? 'Bot' : 'Bots'}` }, String(count)) : null), - pending.length ? h('button', { type: 'button', className: 'fs-tag fs-pending-tag', 'data-flybot': 'pending-badge', title: `${pending.length} outbound ${pending.length === 1 ? 'reply' : 'replies'} waiting for your approval. Opens that chat.`, onClick: openPending }, `${pending.length} to approve`) : null, - refreshing ? h('span', { className: 'fs-refresh', 'data-flybot': 'roster-refreshing' }, 'Refreshing…') : null), - open && homeHandle ? h('div', { className: 'fs-me', 'data-flybot': 'own-home' }, - h('span', { className: 'fs-me-handle', title: homeHandle }, homeHandle), - h('button', { type: 'button', className: 'fs-link', onClick: copyHome }, 'Copy')) : null, - open ? h('div', { className: 'fs-body' }, - unavailable ? null : h(FaceRooms, { actions, enabled: open, roster, remoteChats }), - adding && !unavailable ? h(AddFriend, { onChange: () => load().then(loadSettings), onDone: () => setAdding(false) }) : null, - faceOpen && !unavailable ? h(FaceToFaceGroup, { actions, roster, remoteChats, onClose: () => setFaceOpen(false) }) : null, - unavailable ? h('div', { className: 'fs-empty', 'data-flybot': 'social-unavailable' }, error) : error ? h('div', { className: 'fs-status', 'data-tone': 'error', style: { padding: '4px 10px' } }, error) : null, - homes.map(entry => { - const shut = closed[entry.home] === true - const rows = entry.bots.map(bot => ({ bot, chat: chats.find(chat => chat.remote?.handle === bot.handle) })) - return h('div', { key: entry.home, className: 'bt-social-home', 'data-home': entry.home }, - h('div', { className: 'fs-home-head' }, - h('button', { type: 'button', className: 'fs-home', 'aria-expanded': !shut, title: entry.owner ? `${entry.home} · owned by ${entry.owner}` : entry.home, onClick: () => setClosed(value => ({ ...value, [entry.home]: !shut })) }, - h('span', { className: 'fs-chev', 'aria-hidden': true, 'data-closed': shut || undefined }, '▾'), - h('span', { className: 'fs-home-name' }, entry.name ?? entry.home), - entry.name ? h('span', { className: 'fs-home-handle' }, entry.home) : null), - h('label', { className: 'fs-auto', title: AUTO_SEND_NOTE }, - h('input', { type: 'checkbox', checked: auto[entry.home] === true, disabled: saving === entry.home, onChange: e => toggleAuto(entry.home, e.target.checked) }), - 'Auto-send')), - shut ? null : entry.bots.length === 0 ? h('div', { className: 'fs-hint' }, 'No Bots shared yet') : null, - shut ? null : h('div', { className: 'bt-social-rows' }, rows.map(({ bot, chat }) => h(FriendBotRow, { - key: bot.handle, bot, chat, renderBot, busy: hiding instanceof Set ? hiding.has(bot.handle) : hiding === bot.handle, onRemove: removeBot, - })))) - }), - data && homes.length === 0 && !adding - ? h('div', { className: 'fs-empty' }, 'No friends yet. Use ', h('b', null, 'Copy invite link'), ' in Add friend, or the ', h('b', null, '+'), ' menu → ', h('b', null, 'Add friend'), ' to join a friend’s invite.') - : null) : null) + return h('section', { className: 'fs bt-social', 'aria-label': 'Social' }, + pending.length ? h('button', { + type: 'button', className: 'fs-tag fs-pending-tag', 'data-flybot': 'pending-badge', + title: `${pending.length} outbound ${pending.length === 1 ? 'reply' : 'replies'} waiting for your approval. Opens that chat.`, + onClick: () => openItem(pending[0]), + }, `${pending.length} to approve`) : null, + error ? h('div', { className: 'fs-status', 'data-tone': 'error', style: { padding: '4px 10px' } }, error) : null, + h(FaceRooms, { actions, enabled: true, roster, remoteChats }), + adding ? h(AddFriend, { onChange: loadApprovals, onDone: () => setAdding(false) }) : null, + faceOpen ? h(FaceToFaceGroup, { actions, roster, remoteChats, onClose: () => setFaceOpen(false) }) : null) } // Add friend: join a friend's link and answer requests (invites are made from a Bot's right-click menu). @@ -1362,8 +1244,69 @@ window.__ModuleLoader__.load({ error ? h('div', { className: 'fs-status', 'data-tone': 'error', role: 'status' }, error) : null) } - function BotSocialDetails({ bot }) { + function FriendChatSettings({ bot } = {}) { + const remote = bot?.remote + const home = remote?.home + const handle = remote?.handle + const [auto, setAuto] = useState(false) + const [saving, setSaving] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + useEffect(() => { + if (!home) return undefined + call('social/settings', {}).then(value => setAuto(value?.autoAccept?.[home] === true), () => {}) + }, [home]) + if (!handle) return null + const toggleAuto = async (on) => { + if (!home) return + setSaving(true) + setAuto(on) + setError('') + try { + const result = await call('social/auto-accept', { home, on }) + setAuto(result?.autoAccept === true) + } catch (e) { + setError(errorText(e)) + setAuto(!on) + } finally { setSaving(false) } + } + const removeBot = async () => { + const who = remote.homeName || remote.owner || home || 'Your friend' + const ask = typeof globalThis.confirm === 'function' ? globalThis.confirm : undefined + if (ask && !ask(`Remove ${bot.name}? ${who} can share it again.`)) return + setBusy(true) + setError('') + try { + // social/hide-remote already leaves every room holding this Bot; a second + // removal from here would race it and show "not in the room" after a good Remove. + await call('social/hide-remote', { handle }) + } catch (e) { setError(errorText(e)) } finally { setBusy(false) } + } + return h('div', { className: 'fs fs-card', 'data-flybot': 'friend-chat-settings' }, + h('div', { className: 'fs-name' }, bot.name || handle), + h('div', { className: 'fs-hint' }, handle), + h('label', { className: 'fs-auto', title: AUTO_SEND_NOTE, 'data-flybot': 'friend-auto-send' }, + h('input', { type: 'checkbox', checked: auto === true, disabled: saving || !home, onChange: e => toggleAuto(e.target.checked) }), + 'Auto-send'), + h('button', { + type: 'button', + className: 'fs-btn', + 'data-flybot': 'remove-friend-bot', + title: 'Remove this Bot from your list and from your groups. This does not unfriend the owner.', + 'aria-label': busy ? 'Removing…' : 'Remove this Bot', + 'aria-busy': busy || undefined, + disabled: !!busy, + onClick: () => { void removeBot() }, + }, busy ? 'Removing…' : 'Remove this Bot'), + error ? h('div', { className: 'fs-status', 'data-tone': 'error', role: 'status' }, error) : null) + } + + function BotSocialDetails({ bot } = {}) { useStyles() + if (bot?.remote) { + return h('div', { 'data-flybot': 'bot-social-details' }, + h(FriendChatSettings, { bot })) + } return h('div', { 'data-flybot': 'bot-social-details' }, h(InviteToBot, { bot }), h(ManageAccess, { bot })) @@ -1418,6 +1361,7 @@ window.__ModuleLoader__.load({ ChatApproval, ChatApprovalDock, FriendBotRow, + FriendChatSettings, FaceToFaceGroup, FaceAvatar, FaceRoomOverview, @@ -1483,6 +1427,28 @@ window.__ModuleLoader__.load({ return `Invite link: ${link}` }, }), + slots().register('flybot.new-chat.action', { + id: 'social-copy-handle', + order: 19, + label: 'Copy my handle', + hint: 'Your Bot Social handle', + run: async ({ close } = {}) => { + close?.() + const status = await call('social/status', {}) + const handle = status?.home + if (!handle) return 'Social is not connected yet.' + const clip = typeof navigator !== 'undefined' ? navigator.clipboard : undefined + if (clip?.writeText) { + try { + await clip.writeText(handle) + return `Copied. Your handle is ${handle}` + } catch { + return `Could not copy. Your handle is ${handle}` + } + } + return `Your handle is ${handle}` + }, + }), slots().register('flybot.composer.mention', { id: 'social', order: 100, candidates: friendRows }), slots().register('flybot.bot.details', { id: 'social-invite', order: 100, component: BotSocialDetails }), slots().register('flybot.room.overview', { id: 'social-face', order: 50, component: FaceRoomOverview }), diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 257040ced..37be2a8e8 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -962,7 +962,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const override = typeof fromBotId === 'string' ? flybot.fields.get(fromBotId, 'autoApprove')?.[friendHome] : undefined if (override === true) return 'auto' if (override === false) return 'hold' - // Auto-send (off unless the owner switched it on for this friend in Friends' Bots). + // Auto-send (off unless the owner switched it on for this friend in that Bot's settings). if (autoAcceptOf(friendHome)) return 'auto' const homeSetting = approvalHomes[norm(friendHome)] if (homeSetting) return homeSetting diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 9f73a042e..4d1e3a111 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -509,7 +509,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.new-chat.action#social-accept-invite', 'flybot.new-chat.action#social-copy-invite', 'flybot.composer.mention#social', 'flybot.bot.details#social-invite', 'flybot.room.overview#social-face', 'flybot.session.dock#social-approval']) + 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.new-chat.action#social-accept-invite', 'flybot.new-chat.action#social-copy-invite', 'flybot.new-chat.action#social-copy-handle', 'flybot.composer.mention#social', 'flybot.bot.details#social-invite', 'flybot.room.overview#social-face', 'flybot.session.dock#social-approval']) // 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') @@ -754,7 +754,7 @@ test('MCP client (#18): a hung server times out; an SSE answer is read', async ( assert.deepEqual(await sse.callTool('x'), { ok: true }) }) -test('Friends\' Bots (#21): one group per home, rows drawn by ds-bot\'s own Bot row, no pills', async () => { +test('Friends\' Bots (#21): friends are not a separate per-home group in the social strip', () => { const registered = [] const entries = [] const flybot = { slots: { register: (slot, entry) => { entries.push([slot, entry]); return () => {} } }, call: async () => ({}) } @@ -762,8 +762,7 @@ test('Friends\' Bots (#21): one group per home, rows drawn by ds-bot\'s own Bot sandbox.globalThis = sandbox sandbox.__flybot__ = flybot runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) - const data = { homes: [{ home: '@danyang.home', name: 'Danyang', bots: [{ handle: '@chief.danyang.home', name: 'Chief' }] }] } - const initial = [data, '', true, false, {}, {}, '', [], new Set()] + const initial = ['', false, [], false] let i = 0 const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }) const plugin = registered[0].factory(() => ({ createElement: h, Fragment: 'F', useEffect() {}, useState: v => [i < initial.length ? initial[i++] : v, () => {}], useRef: v => ({ current: v }), useMemo: f => f() })) @@ -778,9 +777,10 @@ test('Friends\' Bots (#21): one group per home, rows drawn by ds-bot\'s own Bot ;(node.children ?? []).forEach(walk) } walk(tree) - assert.deepEqual(drawn, ['@chief.danyang.home']) - assert.ok(all.some(n => n.props.className === 'fs-home-name' && n.children.includes('Danyang'))) - assert.ok(!all.some(n => /remote/.test(String(n.props.className ?? '')) && n.children.includes('remote'))) + assert.deepEqual(drawn, []) + assert.equal(all.some(n => n.props.className === 'fs-home-name'), false) + assert.equal(all.some(n => n.props['data-flybot'] === 'friend-bot'), false) + assert.equal(all.some(n => n.children?.includes?.("Friends' Bots")), false) }) test('nits (#20): approval.homes keys match with or without @; legacy send falls back to a new conversation', async (t) => { @@ -1111,9 +1111,8 @@ test('Friends\' Bots (#69): a pending approval shows a badge and a one-line jump sandbox.globalThis = sandbox sandbox.__flybot__ = flybot runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) - const data = { homes: [{ home: '@hsliu.home', name: 'hsliu', bots: [{ handle: '@hsliu.bot', name: 'hsliu' }] }] } const pending = [{ id: 'apr-hold-1', kind: 'outbound_message', from: '代码审查官', to: '@hsliu.bot', toName: 'hsliu', text: 'I can review that' }] - const initial = [data, '', true, false, {}, {}, '', pending, new Set()] + const initial = ['', false, pending, false] let i = 0 const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }) const plugin = registered[0].factory(() => ({ createElement: h, Fragment: 'F', useEffect() {}, useState: v => [i < initial.length ? initial[i++] : v, () => {}], useRef: v => ({ current: v }), useMemo: f => f() })) @@ -1151,7 +1150,7 @@ test('Friends\' Bots (#68): a start failure is an idle state, never Unknown FlyB sandbox.globalThis = sandbox sandbox.__flybot__ = { slots: { register: (slot, entry) => { entries.push([slot, entry]); return () => {} } }, call: async () => { throw Object.assign(new Error('Unknown FlyBot endpoint social/remote-bots'), { code: 'bot/not-found' }) } } runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) - const initial = [null, 'Social is not running: Bot Social onboarding failed: HTTP 409 (handle @danyang.home is taken). Change displayName in the flybot-social config so a new home is created, then restart.', true, false, {}, {}, '', []] + const initial = ['Social is not running: Bot Social onboarding failed: HTTP 409 (handle @danyang.home is taken). Change displayName in the flybot-social config so a new home is created, then restart.', false, [], false] let i = 0 const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }) const plugin = registered[0].factory(() => ({ createElement: h, Fragment: 'F', useEffect() {}, useState: v => [i < initial.length ? initial[i++] : v, () => {}], useRef: v => ({ current: v }), useMemo: f => f() })) @@ -1175,8 +1174,7 @@ test('Friends\' Bots (#69): no cue when status reports no pending approvals', () sandbox.globalThis = sandbox sandbox.__flybot__ = { slots: { register: (slot, entry) => { entries.push([slot, entry]); return () => {} } }, call: async () => ({}) } runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) - const data = { homes: [] } - const initial = [data, '', true, false, {}, {}, '', []] + const initial = ['', false, [], false] let i = 0 const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }) const plugin = registered[0].factory(() => ({ createElement: h, Fragment: 'F', useEffect() {}, useState: v => [i < initial.length ? initial[i++] : v, () => {}], useRef: v => ({ current: v }), useMemo: f => f() })) @@ -1640,23 +1638,22 @@ test('mapPool runs work concurrently up to the cap', async () => { assert.equal(max, 2) }) -test('Friends\' Bots uses Auto-send for each friend, not a second Auto-accept label', () => { - const { walk, pluginOf, entries } = loadSocialClient(async () => ({ connected: true, bots: [], homes: [], rooms: [] })) - const plugin = pluginOf([ - { homes: [{ home: '@bob.home', name: 'Bob', bots: [{ handle: '@coder.bob.home', name: 'Coder' }] }] }, - '', true, false, {}, {}, '', [], new Set(), false, false, - ]) - plugin.apply({}) - const entry = entries.find(([slot]) => slot === 'flybot.sidebar.section') - const tree = walk(entry[1].component({})) +test('a friend\'s Bot settings uses Auto-send, not a second Auto-accept label', () => { + const { walk, pluginOf } = loadSocialClient(async (endpoint) => { + if (endpoint === 'social/settings') return { autoAccept: { '@bob.home': false } } + return { connected: true, bots: [], homes: [], rooms: [] } + }) + const tree = walk(pluginOf([false, false, false, '']).FriendChatSettings({ + bot: { name: 'Coder', remote: { handle: '@coder.bob.home', home: '@bob.home', homeName: 'Bob' } }, + })) assert.ok(tree.some(n => n.type === 'label' && n.children.includes('Auto-send'))) assert.equal(tree.some(n => n.type === 'label' && n.children.includes('Auto-accept')), false) }) -test('Friends\' Bots header has no + Add; Add friend and Face-to-face group live on the sidebar + menu', () => { +test('social section has no + Add; Add friend and Face-to-face group live on the sidebar + menu', () => { const sidebar = (adding) => { const { entries, walk, pluginOf } = loadSocialClient(async () => ({ connected: true, bots: [{ id: 'a', name: 'Chief', isMain: true }], homes: [], rooms: [] })) - const plugin = pluginOf([{ homes: [] }, '', true, adding, {}, {}, '', [], '', false]) + const plugin = pluginOf(['', adding, [], false]) plugin.apply({}) const entry = entries.find(([slot]) => slot === 'flybot.sidebar.section') return { nodes: walk(entry[1].component({})), entries } @@ -1670,6 +1667,10 @@ test('Friends\' Bots header has no + Add; Add friend and Face-to-face group live assert.ok(closed.entries.some(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-add-friend')) assert.ok(closed.entries.some(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-accept-invite')) assert.ok(closed.entries.some(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-copy-invite')) + assert.ok(closed.entries.some(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-copy-handle')) + assert.equal(closed.nodes.some(n => n.type === 'button' && n.children.includes('Copy')), false) + assert.equal(closed.nodes.some(n => n.props['data-flybot'] === 'own-home'), false) + assert.equal(closed.nodes.some(n => n.children?.includes?.("Friends' Bots")), false) assert.equal(closed.entries.some(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-friend-requests'), false) const opened = sidebar(true) @@ -1681,7 +1682,7 @@ test('Friends\' Bots header has no + Add; Add friend and Face-to-face group live const loading = (() => { const { entries, walk, pluginOf } = loadSocialClient(async () => ({ connected: true, bots: [], homes: [], rooms: [] })) - const plugin = pluginOf([null, '', true, true, {}, {}, '', [], new Set(), false, false]) + const plugin = pluginOf(['', true, [], false]) plugin.apply({}) const entry = entries.find(([slot]) => slot === 'flybot.sidebar.section') return walk(entry[1].component({})) @@ -1691,16 +1692,14 @@ test('Friends\' Bots header has no + Add; Add friend and Face-to-face group live assert.equal(loading.some(n => n.type === 'div' && n.children.includes('Loading…')), false) }) -test('Friends\' Bots mount starts remote-bots, settings, and status in parallel', async () => { +test('social sidebar mount starts status without waiting on a friends roster', async () => { const started = [] let resolved = 0 const { registered, entries, walk } = loadSocialClient(async (endpoint) => { started.push(endpoint) - assert.equal(resolved, 0, `${endpoint} started after another Friends' Bots call finished`) + assert.equal(resolved, 0, `${endpoint} started after another social sidebar call finished`) await new Promise(resolve => setTimeout(resolve, 25)) resolved += 1 - if (endpoint === 'social/remote-bots') return { homes: [] } - if (endpoint === 'social/settings') return { autoAccept: {} } if (endpoint === 'social/status') return { approvals: [], bots: [] } if (endpoint === 'social/face-rooms') return { rooms: [] } return {} @@ -1723,11 +1722,9 @@ test('Friends\' Bots mount starts remote-bots, settings, and status in parallel' const entry = entries.find(([slot]) => slot === 'flybot.sidebar.section') hookI = 0 walk(entry[1].component({})) - assert.ok(started.includes('social/remote-bots')) - assert.ok(started.includes('social/settings')) assert.ok(started.includes('social/status')) - const trio = ['social/remote-bots', 'social/settings', 'social/status'] - assert.deepEqual(started.filter(name => trio.includes(name)).slice(0, 3).sort(), trio.slice().sort()) + assert.equal(started.includes('social/remote-bots'), false) + assert.equal(started.includes('social/settings'), false) }) test('the sidebar + menu gets a Face-to-face group action from social', () => { @@ -1785,6 +1782,8 @@ test('sidebar + Add friend opens the join panel in Friends\' Bots; header stays assert.equal(accept[1].label, 'Accept invite') const copy = entries.find(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-copy-invite') assert.equal(copy[1].label, 'Copy invite link') + const handle = entries.find(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-copy-handle') + assert.equal(handle[1].label, 'Copy my handle') assert.equal(typeof sandbox.__flybot__.social.openAddFriend, 'function') }) @@ -1994,33 +1993,33 @@ test('Face-to-face room card is one compact row with a status chip and no invali assert.match(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), /\.fs-face-row\{[^}]*align-items:center/) }) -test('Face-to-face past rooms sit under a Past rooms fold; Auto-send is once in the header', () => { +test('Face-to-face past rooms sit under a Past rooms fold; live rooms stay off this list', () => { const { walk, pluginOf } = loadSocialClient(async () => ({})) const rooms = [ { faceRoomId: 'frm_live', roomId: 'g1', name: 'Kitchen', status: 'open', members: [{ handle: '@a', displayName: 'A' }] }, { faceRoomId: 'frm_old', roomId: 'g2', name: 'Hall', status: 'closed', members: [] }, { faceRoomId: 'frm_left', roomId: 'g3', name: 'Gone', status: 'left', members: [] }, ] - const plugin = pluginOf([rooms, false, false, '', false]) + const plugin = pluginOf([rooms, false]) const closed = walk(plugin.FaceRooms({})) - assert.ok(closed.some(n => n.props['data-flybot'] === 'face-auto-send')) - assert.equal(closed.filter(n => n.props['data-flybot'] === 'face-auto-send').length, 1) + assert.equal(closed.some(n => n.props['data-flybot'] === 'face-auto-send'), false) assert.ok(closed.some(n => n.props['data-flybot'] === 'face-past')) assert.ok(closed.some(n => n.children?.includes?.('Past rooms') || (Array.isArray(n.children) && n.children.includes('Past rooms')))) - assert.ok(closed.some(n => n.props['data-flybot'] === 'face-room' && n.props['data-status'] === 'open')) + assert.equal(closed.some(n => n.props['data-flybot'] === 'face-room' && n.props['data-status'] === 'open'), false) assert.equal(closed.some(n => n.props['data-status'] === 'closed' || n.props['data-status'] === 'left'), false) - const pluginOpen = pluginOf([rooms, false, false, '', true]) + const pluginOpen = pluginOf([rooms, true]) const opened = walk(pluginOpen.FaceRooms({})) assert.ok(opened.some(n => n.props['data-status'] === 'closed')) assert.ok(opened.some(n => n.props['data-status'] === 'left')) assert.equal(opened.filter(n => n.props['data-flybot'] === 'face-room' && n.props['data-past']).length, 2) assert.equal(opened.filter(n => n.type === 'button' && n.children.includes('Open')).length, 2) - assert.equal(opened.filter(n => n.type === 'button' && n.children.includes('Leave')).length, 1) + assert.equal(opened.filter(n => n.type === 'button' && n.children.includes('Leave')).length, 0) assert.equal(opened.some(n => n.type === 'button' && n.children.includes('Start')), false) + assert.equal(opened.some(n => n.props['data-status'] === 'open'), false) const pastOnly = walk(pluginOf([ [{ faceRoomId: 'frm_old', roomId: 'g2', name: 'Hall', status: 'closed', members: [] }], - false, false, '', false, + false, ]).FaceRooms({})) assert.equal(pastOnly.some(n => n.props.className === 'fs-face-head' || (Array.isArray(n.children) && n.children.includes('Face-to-face rooms'))), false) assert.equal(pastOnly.some(n => n.props['data-flybot'] === 'face-auto-send'), false) @@ -2077,12 +2076,12 @@ test('an ended matching window sits under Past rooms with Open, not Leave', () = { faceRoomId: 'frm_end', roomId: 'g4', name: 'Old meet', status: 'candidate', expiresAt: '2000-01-01T00:00:00.000Z', members: [] }, { faceRoomId: 'frm_live', roomId: 'g1', name: 'Kitchen', status: 'open', members: [{ handle: '@a', displayName: 'A' }] }, ] - const tree = walk(pluginOf([rooms, false, false, '', true]).FaceRooms({})) + const tree = walk(pluginOf([rooms, true]).FaceRooms({})) const ended = tree.find(n => n.props['data-flybot'] === 'face-room' && n.props['data-status'] === 'candidate') assert.ok(ended?.props['data-past']) assert.ok(tree.some(n => n.props['data-flybot'] === 'face-status' && n.children.includes('ended'))) assert.ok(tree.some(n => n.type === 'button' && n.children.includes('Open'))) - assert.equal(tree.filter(n => n.type === 'button' && n.children.includes('Leave')).length, 1) + assert.equal(tree.filter(n => n.type === 'button' && n.children.includes('Leave')).length, 0) }) test('Face-to-face past rooms hide duplicate ids and keep Open as the only action', () => { @@ -2091,7 +2090,7 @@ test('Face-to-face past rooms hide duplicate ids and keep Open as the only actio { faceRoomId: 'frm_old', roomId: 'g2', name: 'Hall', status: 'closed', members: [] }, { faceRoomId: 'frm_old', roomId: 'g2', name: 'Hall', status: 'left', members: [] }, ] - const tree = walk(pluginOf([rooms, false, false, '', true]).FaceRooms({})) + const tree = walk(pluginOf([rooms, true]).FaceRooms({})) assert.equal(tree.filter(n => n.props['data-flybot'] === 'face-room').length, 1) assert.ok(tree.some(n => n.type === 'button' && n.children.includes('Open'))) assert.equal(tree.some(n => n.type === 'button' && n.children.includes('Leave')), false) @@ -2134,7 +2133,7 @@ test('#126: a face-room hold shows in that chat with a readable name', () => { assert.ok(tree.some(n => n.props.className === 'fs-draft' && n.children.includes('hello there'))) }) -test('Face-to-face rooms list polls Bot Social while Friends\' Bots is open', () => { +test('Face-to-face rooms list polls Bot Social for Past rooms', () => { const calls = [] const { walk, pluginOf } = loadSocialClient(async (endpoint, payload) => { calls.push([endpoint, payload]) @@ -2971,7 +2970,7 @@ test('#90: reject goes through decide-approval and leaves a chat note', async (t assert.ok(notes.some(n => n.text === REJECT_NOTE && n.handle === '@hsliu.bot')) }) -test('#90: Friends\' Bots has Remove this Bot; ChatApproval is owner-only', () => { +test('#90: a friend\'s Bot settings has Remove this Bot; ChatApproval is owner-only', () => { const registered = [] const entries = [] const hidden = [] @@ -2982,7 +2981,6 @@ test('#90: Friends\' Bots has Remove this Bot; ChatApproval is owner-only', () = if (endpoint === 'social/hide-remote') { hidden.push(payload); return { handle: payload.handle, hidden: true, homes: [{ home: '@hsliu.home', name: 'hsliu', bots: [] }] } } if (endpoint === 'social/decide-approval') { decided.push(payload); return { approvalId: payload.approvalId, status: payload.decision } } if (endpoint === 'social/status') return { connected: true, pendingApprovals: 1, approvals: [{ id: 'apr-hold-1', kind: 'outbound_message', from: 'Chief', to: '@hsliu.bot', text: 'I can review that' }] } - if (endpoint === 'social/remote-bots') return { homes: [{ home: '@hsliu.home', name: 'hsliu', bots: [{ handle: '@hsliu.bot', name: 'hsliu' }] }] } if (endpoint === 'social/settings') return { autoAccept: {} } return {} }, @@ -2991,15 +2989,13 @@ test('#90: Friends\' Bots has Remove this Bot; ChatApproval is owner-only', () = sandbox.globalThis = sandbox sandbox.__flybot__ = flybot runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) - const data = { homes: [{ home: '@hsliu.home', name: 'hsliu', bots: [{ handle: '@hsliu.bot', name: 'hsliu' }] }] } const pending = [{ id: 'apr-hold-1', kind: 'outbound_message', from: 'Chief', to: '@hsliu.bot', text: 'I can review that' }] - const initial = [data, '', true, false, {}, {}, '', pending, new Set()] + const initial = [false, false, false, ''] let i = 0 const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }) const plugin = registered[0].factory(() => ({ createElement: h, Fragment: 'F', useEffect() {}, useState: v => [i < initial.length ? initial[i++] : v, () => {}], useRef: v => ({ current: v }), useMemo: f => f() })) plugin.apply({}) assert.ok(entries.some(([slot, entry]) => slot === 'flybot.session.dock' && entry.id === 'social-approval')) - const tree = entries.find(([slot]) => slot === 'flybot.sidebar.section')[1].component({ renderBot: () => h('BotRow', {}), remoteChats: [], actions: {} }) const all = [] const walk = node => { if (!node || typeof node !== 'object') return @@ -3007,25 +3003,21 @@ test('#90: Friends\' Bots has Remove this Bot; ChatApproval is owner-only', () = all.push(node) ;(node.children ?? []).forEach(walk) } - walk(tree) + walk(plugin.BotSocialDetails({ bot: { name: 'hsliu', remote: { handle: '@hsliu.bot', home: '@hsliu.home', homeName: 'hsliu' } } })) const remove = all.find(n => n.props['data-flybot'] === 'remove-friend-bot') assert.ok(remove) assert.equal(remove.props['aria-label'], 'Remove this Bot') assert.ok(remove.props.title) - assert.ok(remove.children.includes('×')) - assert.equal(remove.children.includes('Remove this Bot'), false) - assert.equal(remove.props.className, 'fs-remove') - assert.ok(all.some(n => n.props['data-flybot'] === 'friend-bot')) + assert.ok(remove.children.includes('Remove this Bot')) + assert.ok(all.some(n => n.props['data-flybot'] === 'friend-chat-settings')) + assert.ok(all.some(n => n.type === 'label' && n.children.includes('Auto-send'))) const src = readFileSync(new URL('../client.js', import.meta.url), 'utf8') - assert.match(src, /\.fs-row-wrap:hover \.fs-remove/) - assert.match(src, /\.fs-row-wrap:focus-within \.fs-remove/) - assert.match(src, /\.fs-remove\{[^}]*width:20px/) + assert.match(src, /data-flybot': 'remove-friend-bot/) remove.props.onClick({ stopPropagation() {} }) const owner = plugin.ChatApproval({ item: pending[0], canDecide: true, onDecide: (item, decision) => decided.push([item.id, decision]) }) const other = plugin.ChatApproval({ item: pending[0], canDecide: false }) const ownerNodes = [] const otherNodes = [] - walk.owner = undefined const collect = (node, into) => { if (node && typeof node === 'object') { into.push(node); (node.children ?? []).forEach(c => collect(c, into)) } } collect(owner, ownerNodes) collect(other, otherNodes) @@ -3046,23 +3038,50 @@ test('#90: Friends\' Bots has Remove this Bot; ChatApproval is owner-only', () = assert.ok(doneNodes.some(n => n.children?.includes?.('Approved') || (Array.isArray(n.children) && n.children.includes('Approved')))) }) -test('#90: two Remove clicks keep independent pending state', async () => { +test('Remove this Bot leaves rooms only through social/hide-remote, not a second client-side removal', async () => { const registered = [] - const blocked = new Map() + const hidden = [] + const updates = [] + const flybot = { + slots: { register: () => () => {} }, + call: async (endpoint, payload) => { + if (endpoint === 'social/hide-remote') { hidden.push(payload); return { handle: payload.handle, hidden: true, leftRooms: [{ id: 'g1', origin: 'group' }], homes: [] } } + if (endpoint === 'social/settings') return { autoAccept: {} } + return {} + }, + } + const sandbox = { window: { __ModuleLoader__: { load: r => registered.push(r) } }, setTimeout, clearTimeout, console, document: { getElementById: () => ({}), createElement: () => ({}), head: { appendChild() {} } } } + sandbox.globalThis = sandbox + sandbox.__flybot__ = flybot + runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) + const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }) + const plugin = registered[0].factory(() => ({ createElement: h, Fragment: 'F', useEffect() {}, useState: v => [v, () => {}], useRef: v => ({ current: v }), useMemo: f => f() })) + plugin.apply({}) + const roster = { rooms: [{ id: 'g1', participants: [{ kind: 'bot', id: '@hsliu.bot', handle: '@hsliu.bot', remote: true }] }] } + const actions = { roster, updateRoom: async (id, change) => { updates.push([id, change]) } } + const all = [] + const walk = node => { + if (!node || typeof node !== 'object') return + if (typeof node.type === 'function') { walk(node.type(node.props)); return } + all.push(node) + ;(node.children ?? []).forEach(walk) + } + walk(plugin.BotSocialDetails({ bot: { name: 'hsliu', remote: { handle: '@hsliu.bot', home: '@hsliu.home', homeName: 'hsliu' } }, roster, actions })) + const remove = all.find(n => n.props['data-flybot'] === 'remove-friend-bot') + remove.props.onClick({ stopPropagation() {} }) + await new Promise(resolve => setTimeout(resolve, 0)) + assert.deepEqual(hidden.map(p => p.handle), ['@hsliu.bot']) + assert.equal(updates.length, 0) +}) + +test('#90: Remove on a friend\'s Bot stays busy until hide-remote finishes', async () => { + const registered = [] + let release const flybot = { slots: { register: (slot, entry) => { registered.push([slot, entry]); return () => {} } }, call: (endpoint, payload) => { if (endpoint === 'social/hide-remote') { - return new Promise(resolve => { blocked.set(payload.handle, resolve) }) - } - if (endpoint === 'social/status') return Promise.resolve({ connected: true, pendingApprovals: 0, approvals: [] }) - if (endpoint === 'social/remote-bots') { - return Promise.resolve({ - homes: [{ - home: '@hsliu.home', name: 'hsliu', - bots: [{ handle: '@hsliu.bot', name: 'hsliu' }, { handle: '@coder.bob.home', name: 'Coder' }], - }], - }) + return new Promise(resolve => { release = () => resolve({ handle: payload.handle, hidden: true }) }) } if (endpoint === 'social/settings') return Promise.resolve({ autoAccept: {} }) return Promise.resolve({}) @@ -3085,18 +3104,10 @@ test('#90: two Remove clicks keep independent pending state', async () => { useRef: v => ({ current: v }), useMemo: f => f(), })) plugin.apply({}) - const seed = { - homes: [{ - home: '@hsliu.home', name: 'hsliu', - bots: [{ handle: '@hsliu.bot', name: 'hsliu' }, { handle: '@coder.bob.home', name: 'Coder' }], - }], - } const snapshot = () => { hookI = 0 - const tree = registered.find(entry => Array.isArray(entry) && entry[0] === 'flybot.sidebar.section')[1].component({ - renderBot: chat => h('BotRow', { handle: chat.remote?.handle }, chat.remote?.handle), - remoteChats: [{ remote: { handle: '@hsliu.bot' } }, { remote: { handle: '@coder.bob.home' } }], - actions: {}, + const tree = plugin.FriendChatSettings({ + bot: { name: 'hsliu', remote: { handle: '@hsliu.bot', home: '@hsliu.home', homeName: 'hsliu' } }, }) const all = [] const walk = node => { @@ -3108,38 +3119,17 @@ test('#90: two Remove clicks keep independent pending state', async () => { walk(tree) return all } - hookI = 0 - hooks.length = 0 - snapshot() - hooks[0] = seed - const rowButton = (nodes, handle) => { - const row = nodes.find(n => n.props['data-flybot'] === 'friend-bot' && n.props['data-handle'] === handle) - return (row?.children ?? []).find(n => n.props?.['data-flybot'] === 'remove-friend-bot') - } - rowButton(snapshot(), '@hsliu.bot').props.onClick({ stopPropagation() {} }) - const afterFirst = snapshot() - const firstBtn = rowButton(afterFirst, '@hsliu.bot') - const otherBtn = rowButton(afterFirst, '@coder.bob.home') - assert.equal(firstBtn.props.disabled, true) - assert.equal(firstBtn.props['aria-label'], 'Removing…') - assert.ok(firstBtn.children.includes('…')) - assert.equal(otherBtn.props.disabled, false) - assert.equal(otherBtn.props['aria-label'], 'Remove this Bot') - otherBtn.props.onClick({ stopPropagation() {} }) - const afterBoth = snapshot() - assert.equal(rowButton(afterBoth, '@hsliu.bot').props.disabled, true) - assert.equal(rowButton(afterBoth, '@hsliu.bot').props['aria-label'], 'Removing…') - assert.equal(rowButton(afterBoth, '@coder.bob.home').props.disabled, true) - assert.equal(rowButton(afterBoth, '@coder.bob.home').props['aria-label'], 'Removing…') - blocked.get('@hsliu.bot')({ - handle: '@hsliu.bot', hidden: true, - homes: [{ home: '@hsliu.home', name: 'hsliu', bots: [{ handle: '@coder.bob.home', name: 'Coder' }] }], - }) + const button = nodes => nodes.find(n => n.props['data-flybot'] === 'remove-friend-bot') + button(snapshot()).props.onClick() + const busy = button(snapshot()) + assert.equal(busy.props.disabled, true) + assert.equal(busy.props['aria-label'], 'Removing…') + assert.ok(busy.children.includes('Removing…')) + release() await new Promise(resolve => setTimeout(resolve, 0)) - const afterDone = snapshot() - assert.equal(rowButton(afterDone, '@hsliu.bot'), undefined) - assert.equal(rowButton(afterDone, '@coder.bob.home').props.disabled, true) - assert.equal(rowButton(afterDone, '@coder.bob.home').props['aria-label'], 'Removing…') + const done = button(snapshot()) + assert.equal(done.props.disabled, false) + assert.equal(done.props['aria-label'], 'Remove this Bot') }) test('#90: social/access lists who has the Bot; revoke uses the isolated grant API', async (t) => { @@ -3611,21 +3601,26 @@ test('createSocialApi prefers list_bot_grants', async () => { assert.equal(server.calls.some(c => c.name === 'list_friends' && c.args?.bot_id === 'm1'), false) }) -test('#136: Friends\' Bots shows my handle with Copy; Auto-send starts unchecked; Join-as is always offered', () => { - const { walk, pluginOf, entries } = loadSocialClient(async () => ({ connected: true, home: '@selfa1935.home', bots: [{ id: 'a', name: 'Chief', isMain: true }], homes: [], rooms: [] })) - const plugin = pluginOf([ - { homes: [{ home: '@bob.home', name: 'Bob', bots: [{ handle: '@coder.bob.home', name: 'Coder' }] }] }, - '', true, false, {}, {}, '', [], new Set(), false, false, '@selfa1935.home', - ]) - plugin.apply({}) - const entry = entries.find(([slot]) => slot === 'flybot.sidebar.section') - const tree = walk(entry[1].component({})) - const me = tree.find(n => n.props['data-flybot'] === 'own-home') - assert.ok(me) - assert.ok(me.children.some(c => typeof c === 'object' && c.children?.includes?.('@selfa1935.home'))) - assert.ok(tree.some(n => n.type === 'button' && n.children.includes('Copy'))) - const box = tree.find(n => n.type === 'input' && n.props.type === 'checkbox' && n.props.checked === true) - assert.equal(box, undefined) +test('#136: + Copy my handle copies the home; Auto-send starts unchecked; Join-as is always offered', async () => { + const copied = [] + const { walk, pluginOf, entries, sandbox } = loadSocialClient(async (endpoint) => { + if (endpoint === 'social/status') return { connected: true, home: '@selfa1935.home', bots: [{ id: 'a', name: 'Chief', isMain: true }], approvals: [] } + if (endpoint === 'social/settings') return { autoAccept: {} } + return {} + }) + sandbox.navigator = { clipboard: { writeText: async (text) => { copied.push(text) } } } + pluginOf([]).apply({}) + const copy = entries.find(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-copy-handle') + assert.ok(copy) + assert.equal(copy[1].label, 'Copy my handle') + const note = await copy[1].run({ close: () => {} }) + assert.equal(note, 'Copied. Your handle is @selfa1935.home') + assert.deepEqual(copied, ['@selfa1935.home']) + const settings = walk(pluginOf([false, false, false, '']).FriendChatSettings({ + bot: { name: 'Coder', remote: { handle: '@coder.bob.home', home: '@bob.home', homeName: 'Bob' } }, + })) + const box = settings.find(n => n.type === 'input' && n.props.type === 'checkbox') + assert.equal(box.props.checked, false) const add = walk(pluginOf([null, '', null, false, [{ id: 'a', name: 'Chief', isMain: true }], 'a']).AddFriend({})) assert.ok(add.some(n => n.props['data-flybot'] === 'join-as')) assert.match(add.find(n => n.props['data-flybot'] === 'join-as').children.join(''), /not shared/i) @@ -3634,23 +3629,17 @@ test('#136: Friends\' Bots shows my handle with Copy; Auto-send starts unchecked test('#139: Remove asks once; cancelling leaves the Bot', () => { const asked = [] const hidden = [] - const { walk, pluginOf, sandbox, entries } = loadSocialClient(async (endpoint, payload) => { + const { walk, pluginOf, sandbox } = loadSocialClient(async (endpoint, payload) => { if (endpoint === 'social/hide-remote') { hidden.push(payload); return { handle: payload.handle, hidden: true, homes: [{ home: '@bob.home', name: 'Bob', bots: [] }] } } - if (endpoint === 'social/status') return { connected: true, home: '@me.home', approvals: [] } - if (endpoint === 'social/remote-bots') return { homes: [{ home: '@bob.home', name: 'Bob', bots: [{ handle: '@coder.bob.home', name: 'Helper' }] }] } if (endpoint === 'social/settings') return { autoAccept: {} } return {} }) sandbox.confirm = (text) => { asked.push(text); return false } - const plugin = pluginOf([ - { homes: [{ home: '@bob.home', name: 'Bob', bots: [{ handle: '@coder.bob.home', name: 'Helper' }] }] }, - '', true, false, {}, {}, '', [], new Set(), false, false, '', - ]) - plugin.apply({}) - const entry = entries.find(([slot]) => slot === 'flybot.sidebar.section') - const tree = walk(entry[1].component({})) + const tree = walk(pluginOf([false, false, false, '']).FriendChatSettings({ + bot: { name: 'Helper', remote: { handle: '@coder.bob.home', home: '@bob.home', homeName: 'Bob' } }, + })) const remove = tree.find(n => n.props['data-flybot'] === 'remove-friend-bot') - remove.props.onClick({ stopPropagation() {} }) + remove.props.onClick() assert.equal(asked.length, 1) assert.match(asked[0], /Remove Helper\?/) assert.equal(hidden.length, 0)