diff --git a/README.md b/README.md index 16b7c12b8..74b37f3dc 100644 --- a/README.md +++ b/README.md @@ -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 onboarding fails because that home handle is already taken (HTTP 409), **Friends' Bots** shows the handle and says to change `displayName` in this config, then restart. 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. **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. -A friend's inbound messages arrive immediately. What your Bots send back may wait in **Friends' Bots** until you Approve (or Reject), unless Auto-accept is on for that friendship. If a friend sees no reply, look for the **to approve** badge there. +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**. 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 42f564e3e..817c93cb1 100644 --- a/README.zh.md +++ b/README.zh.md @@ -114,9 +114,9 @@ export PORT=8787 displayName: 你的名字 ``` -首次启动只登记你家。在你授权之前,没有任何 Bot 被共享。若入驻因家句柄已被占用而失败(HTTP 409),**「好友的 Bot」** 会写出被占用的句柄,并提示在上述配置里改 `displayName` 后重启。授权某个 Bot:右键该 Bot → **邀请使用此 Bot**,把链接发给对方;对方在侧栏 **+** → **添加好友** 里粘贴。接受邀请只授权这一个 Bot。 +首次启动只登记你家。在你授权之前,没有任何 Bot 被共享。若家句柄已被占用,FlyBot 会自动改用带后缀的名字(`你的名字2` …),而不用你去改配置。**「好友的 Bot」** 会显示你的句柄并提供复制。授权某个 Bot:右键该 Bot → **邀请使用此 Bot**,把链接发给对方;对方在侧栏 **+** → **添加好友** 里粘贴。接受邀请只授权这一个 Bot。 -好友发来的消息会立刻送达。你的 Bot 回过去的内容可能停在 **「好友的 Bot」** 里等你批准(或拒绝),除非对该好友开了自动接受。如果对方只看见自己的话,先看侧栏的 **待批准** 角标。 +好友发来的消息会立刻送达。你的 Bot 回过去的内容会停在当前聊天里等你批准(或拒绝)。新好友默认关闭自动发送。如果对方只看见自己的话,先看 **「好友的 Bot」** 里的 **待批准** 角标。 未配置 `server` 时,社交插件会故意保持空闲。树插件不依赖 Bot Social。 diff --git a/changelog.d/136-onboard-suffix.fixed.md b/changelog.d/136-onboard-suffix.fixed.md new file mode 100644 index 000000000..dae7b9d86 --- /dev/null +++ b/changelog.d/136-onboard-suffix.fixed.md @@ -0,0 +1 @@ +A taken Bot Social home name at first start retries `FlyBot2`, `FlyBot3`, … instead of failing until the owner edits YAML, and Friends' Bots shows the connected handle with Copy. ([#136](https://github.com/ThinkFlowLab/FlyBot/issues/136)) diff --git a/changelog.d/138-invite-grant-scope.security.md b/changelog.d/138-invite-grant-scope.security.md new file mode 100644 index 000000000..7aed9ac4d --- /dev/null +++ b/changelog.d/138-invite-grant-scope.security.md @@ -0,0 +1 @@ +Accepting a per-Bot invite grants only that Bot: the joiner's talk-through Bot is not shared back, Manage access lists people by name and home, and leaving a face-to-face room drops room-only remotes from Friends' Bots. ([#138](https://github.com/ThinkFlowLab/FlyBot/issues/138)) diff --git a/changelog.d/139-remove-readd.fixed.md b/changelog.d/139-remove-readd.fixed.md new file mode 100644 index 000000000..2de4455b7 --- /dev/null +++ b/changelog.d/139-remove-readd.fixed.md @@ -0,0 +1 @@ +Removing a friend's Bot asks once, and joining again after a hide unhides that Bot with English errors instead of raw `relationship_exists` / `invite is used`. ([#139](https://github.com/ThinkFlowLab/FlyBot/issues/139)) diff --git a/changelog.d/140-approval-default.fixed.md b/changelog.d/140-approval-default.fixed.md new file mode 100644 index 000000000..3cdcbf388 --- /dev/null +++ b/changelog.d/140-approval-default.fixed.md @@ -0,0 +1 @@ +New friends start with Auto-send off, the owner sees Approve / Reject in the chat (not a waiting bubble), and a message the owner typed is labelled You. ([#140](https://github.com/ThinkFlowLab/FlyBot/issues/140)) diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 6c01428c6..22b65f5b9 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -41,13 +41,13 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers 1. Social code stays inside `plugins/social/`. 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. -4. Access is a per-Bot **grant** (#90): 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). Accepting **Invite to this Bot** is the grant; `social/access` + `social/revoke-grant` (via `mcp-client.js` `listGrants` / `revokeGrant`, MCP then REST) let the owner see where the Bot is in use and revoke one person without touching other grants. 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`). A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and drops `roomOnly`. -5. When start fails (including Bot Social onboarding HTTP 409 `handle_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, `flybot.warn` records the failure in diagnostics, and a 409 names the taken handle (`@.home`) and says to change `displayName` in the flybot-social config. The client also maps `bot/not-found` on `social/*` to that idle state. +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. 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 an inline card in the open chat (`flybot.session.dock`): draft text, **Approve** / **Reject** for the owner, **Approve all** when several, and **Waiting for the owner's approval** with no buttons for anyone else. The owner's click is consent (`confirm_code` in the same `decide_approval` 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. +- Held Bot Social drafts (`social/status`, `social/decide-approval`) and matching local `flybot/approvals` holds show as an inline card in the open chat (`flybot.session.dock`): draft text, **Approve** / **Reject** for the owner, **Approve all** when several, and **Waiting for the owner's approval** with no buttons for anyone else. 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` 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. ### Face-to-face waiting grid, leave, and social + (#124, #125, #126) @@ -64,6 +64,14 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers - Syncing a held outbound draft into chat must not call `remote.chats.deliver` with a home-level handle (`@box.home`) or with no session. Resolve a listed friend Bot under that home, `chats.open` that 1:1 if needed, then deliver so the waiting line lands. If no Bot/session can be opened, skip with a clear log; never throw into the poll loop. +### 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 (deferred):** The owner's chosen Bot avatar/color does not cross homes. Live Bot Social `update_bot_profile` only has `display_name` / `bot_id`; face-room join POST is `{ code, bots: [handle] }`. Peers keep the handle-seeded look until the server grows profile look fields. +- **#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)`. Face-room remotes leave Friends' Bots when the room is left. +- **#139:** **Remove this Bot** asks once (`Remove Helper? Bob can share it again.`). Hide stays local (A's Manage access still shows the grant until A revokes — an incoming un-grant needs Bot Social). Re-joining an existing friendship unhides instead of a raw `relationship_exists`; used invites say “This invite was already used.” +- **#140:** Auto-send defaults off. The owner sees Approve / Reject in the chat, not “Waiting for the owner's approval.” Composer drafts are labelled You. + ### 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 include the other owners, else the 6-digit code, else a clock time — not a pile of identical "Face-to-face room". diff --git a/plugins/social/README.md b/plugins/social/README.md index 73a3c913f..56312a184 100644 --- a/plugins/social/README.md +++ b/plugins/social/README.md @@ -6,10 +6,10 @@ browser: `client.js`. Design: see "Social design: homes and members" in the root - **One-click onboarding**: on first start, `POST /v1/onboard/flybot` creates the owner, the home identity (`@.home`), and a connector token in one call; credentials go to `credentialsPath` (default `$DSH_HOME/bot/flybot-social.json`, mode 600). - If that call fails (for example HTTP 409 because `@.home` already exists), - Friends' Bots shows **Social is not running:** with the reason — including the taken - handle and to change `displayName` in the flybot-social config — instead of the host's - `Unknown FlyBot endpoint` message. The same failure is written to Settings → Feedback. + 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 + 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 flag: access is a per-Bot **grant** (Invite to this Bot → accept). Default deny, Chief included. @@ -29,7 +29,7 @@ browser: `client.js`. Design: see "Social design: homes and members" in the root `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”. 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-accept on a friendship skips the hold for later messages. Room messages (`face_room_message`) use the same queue; **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`. +- **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 diff --git a/plugins/social/client.js b/plugins/social/client.js index 849f66850..b88ae8dea 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -24,9 +24,12 @@ window.__ModuleLoader__.load({ if (http) return `The FlyBot host did not answer (HTTP ${http[1]}). Reload the page; if this keeps happening, restart FlyBot web.` if (code === 'NETWORK') return 'Could not reach FlyBot. Check that it is still running.' if (isSocialMissing(e)) { - return 'Social is not running: the plugin did not start. If a home handle was taken, change displayName in the flybot-social config and restart. Otherwise see Settings → Feedback.' + return 'Social is not running: the plugin did not start. See Settings → Feedback.' } - return String(e?.message ?? e) + const message = String(e?.message ?? e ?? '') + if (/relationship_exists/i.test(message)) return "You're already friends. That Bot is in Friends' Bots again." + if (/invite is used|invite_used|already used/i.test(message)) return 'This invite was already used. Ask your friend for a new one.' + return message } // Styles reuse ds-bot's sidebar tokens (--bt-ink, --bt-ink-2, --bt-ink-3, --bt-hover, --bt-line, @@ -85,6 +88,9 @@ window.__ModuleLoader__.load({ .fs-status[data-tone=error]{color:#e5484d} .fs-pending-tag{background:var(--bt-accent,#4d6bfe);color:#fff} .fs-refresh{flex:none;font-size:11px;line-height:14px;color:var(--bt-ink-3,rgba(20,20,20,.36));font-weight:400} +.fs-me{display:flex;align-items:center;gap:6px;padding:0 10px 4px;font-size:11px;line-height:14px;color:var(--bt-ink-3,rgba(20,20,20,.36))} +.fs-me-handle{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.fs-me .fs-link{flex:none} .fs-pending{margin:4px 4px 6px;padding:6px 8px;border:1px solid var(--bt-line-2,rgba(20,20,20,.15));border-radius:10px;background:var(--bt-card,#fff);display:flex;flex-direction:column;gap:4px;min-width:0;max-width:100%} .fs-pending .fs-label{padding:0} .fs-pending-item{display:flex;align-items:center;gap:8px;width:100%;max-width:100%;min-width:0;padding:4px 4px;border:0;border-radius:8px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;flex-wrap:nowrap} @@ -676,10 +682,11 @@ window.__ModuleLoader__.load({ function localHoldView(approval) { if (!approval?.id) return undefined const to = approval.targetName ?? (typeof approval.request?.to === 'object' ? approval.request.to.handle : approval.request?.to) + const composer = approval.request?.via === 'composer' return { id: approval.id, source: 'local', - from: approval.senderName, + from: composer ? 'You' : approval.senderName, to, toName: approval.targetName || to, text: approval.request?.text || approval.reason, @@ -972,6 +979,7 @@ window.__ModuleLoader__.load({ 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 => { @@ -983,6 +991,7 @@ window.__ModuleLoader__.load({ 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 = () => { @@ -1010,7 +1019,12 @@ window.__ModuleLoader__.load({ const unavailable = error.startsWith('Social is not running:') const toggleAuto = async (home, on) => { setSaving(home) - try { const r = await call('social/auto-accept', { home, on }); setAuto(value => ({ ...value, [home]: r.autoAccept })) } catch (e) { setError(errorText(e)) } finally { setSaving('') } + 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 => { @@ -1021,6 +1035,10 @@ window.__ModuleLoader__.load({ }) } 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 }) @@ -1058,6 +1076,9 @@ window.__ModuleLoader__.load({ 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, @@ -1073,7 +1094,7 @@ window.__ModuleLoader__.load({ 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] !== false, disabled: saving === entry.home, onChange: e => toggleAuto(entry.home, e.target.checked) }), + 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, { @@ -1112,7 +1133,8 @@ window.__ModuleLoader__.load({ await refresh() onChange?.() } catch (e) { - setStatus({ tone: 'error', text: `${label} failed: ${errorText(e)}` }) + const text = errorText(e) + setStatus({ tone: 'error', text: /already used|already friends/i.test(text) ? text : `${label} failed: ${text}` }) } finally { setBusy(false) } @@ -1139,8 +1161,10 @@ window.__ModuleLoader__.load({ return run('Joining', async () => { const r = await call('social/join-invite', { code, ...(joiner ? { botId: joiner } : {}) }) setCode('') - const as = r.joinedAs ? ` as ${r.joinedAs.botName}` : '' - return r.status === 'active' ? `You are now friends with ${r.inviterName ?? r.inviter}${as}.` : `Request sent to ${r.inviterName ?? r.inviter}${as}. They show up here once they accept.` + const who = r.inviterName ?? r.inviter ?? 'Your friend' + const shared = r.inviterBotName || r.sharedBot + if (r.status === 'active') return shared ? `${who} shared ${shared} with you.` : `${who} shared a Bot with you.` + return `Request sent to ${who}. They show up here once they accept.` }) } const respond = (item, decision) => run(decision === 'accept' ? 'Accepting' : 'Declining', async () => { @@ -1161,10 +1185,10 @@ window.__ModuleLoader__.load({ h('div', { className: 'fs-line' }, h('input', { className: 'fs-input', value: code, placeholder: 'Paste invite link or code', 'aria-label': 'Invite link or code', onChange: e => setCode(e.target.value) }), h('button', { type: 'submit', className: 'fs-btn', disabled: busy || !code.trim() }, 'Join')), - mine.length > 1 ? h('label', { className: 'fs-line fs-hint', 'data-flybot': 'join-as' }, 'Join as ', - h('select', { className: 'fs-input', value: joiner, 'aria-label': 'Which of your Bots joins', onChange: e => setJoiner(e.target.value) }, + mine.length ? h('label', { className: 'fs-line fs-hint', 'data-flybot': 'join-as' }, 'Talk through ', + h('select', { className: 'fs-input', value: joiner, 'aria-label': 'Which of your Bots talks', onChange: e => setJoiner(e.target.value) }, mine.map(bot => h('option', { key: bot.id, value: bot.id }, bot.name))), - ' (your friend sees only this Bot)') : null), + ' (not shared unless you invite them to it)') : null), status ? h('div', { className: 'fs-status', role: 'status', 'data-tone': status.tone, style: { padding: '2px 10px' } }, status.text) : null, incoming.length ? h('div', { className: 'bt-social-requests' }, h('div', { className: 'fs-label' }, 'Friend requests', h('span', { className: 'fs-count' }, String(incoming.length))), @@ -1241,7 +1265,9 @@ window.__ModuleLoader__.load({ grants.map(grant => h('div', { key: grant.id || grant.grantee, className: 'fs-line', 'data-flybot': 'access-grant' }, h('div', { className: 'fs-col' }, h('span', { className: 'fs-name' }, grant.name || grant.grantee), - h('span', { className: 'fs-sub' }, grant.grantee), + grant.home && grant.name && !String(grant.name).includes(grant.home) + ? h('span', { className: 'fs-sub' }, grant.home) + : null, (grant.rooms ?? []).length ? h('span', { className: 'fs-hint' }, `In use: ${(grant.rooms ?? []).map(room => room.name || room.id).join(', ')}`) : null), h('button', { type: 'button', className: 'fs-btn', disabled: Boolean(busy), diff --git a/plugins/social/src/hidden-remotes.js b/plugins/social/src/hidden-remotes.js index 776e23e96..18b0f9b2a 100644 --- a/plugins/social/src/hidden-remotes.js +++ b/plugins/social/src/hidden-remotes.js @@ -43,6 +43,14 @@ export function homeOfHiddenHandle(handle) { // Drop a hide at once (an explicit revoke event). Refresh still needs two successful // absences before it forgets on its own. +export function revealHiddenIfPresent(settings, presentHandles) { + let next = settings + for (const handle of presentHandles ?? []) { + if (isHiddenRemote(next, handle)) next = clearHiddenRemote(next, handle) + } + return next +} + export function clearHiddenRemote(settings, handle) { const key = normHiddenHandle(handle) if (!key || key === '@') return settings diff --git a/plugins/social/src/mcp-client.js b/plugins/social/src/mcp-client.js index c33e52815..4b23ecfcd 100644 --- a/plugins/social/src/mcp-client.js +++ b/plugins/social/src/mcp-client.js @@ -166,15 +166,56 @@ function asList(value) { return [] } +// Chief@chief.selfb1935.home / @chief.selfb1935.home / @selfb1935.home → a comparable handle. +export function memberHandleOf(value) { + const raw = String(value ?? '').trim() + if (!raw) return '' + const nested = raw.match(/@([^@\s]+)$/) + const body = (nested ? nested[1] : raw).replace(/^@+/, '').toLowerCase().replace(/[^a-z0-9._-]/g, '').slice(0, 120) + return body ? `@${body}` : '' +} + +// A person home from a grant peer: @chief.selfb1935.home → @selfb1935.home. +export function personHomeOf(handle) { + const key = memberHandleOf(handle) + if (!key) return '' + const parts = key.slice(1).split('.') + if (parts.length >= 2 && parts.at(-1) === 'home') return `@${parts.at(-2)}.home` + return key +} + +function titleHome(home) { + const part = String(home ?? '').replace(/^@/, '').replace(/\.home$/i, '') + if (!part) return '' + return part.charAt(0).toUpperCase() + part.slice(1) +} + +export function grantPersonView(grant) { + const raw = String(grant?.grantee ?? '') + const member = memberHandleOf(raw) + const home = personHomeOf(raw) + const rawName = String(grant?.name ?? '').trim() + const nameLooksLikeHandle = /@/.test(rawName) || /[a-z0-9]+@[a-z0-9.]/i.test(rawName) + const person = (!nameLooksLikeHandle && rawName) ? rawName.replace(/\s+/g, ' ').trim().slice(0, 60) : titleHome(home) + const label = home ? `${person} (${home})` : person + return { home, member, person, label } +} + export function normalizeGrant(item) { if (!item || typeof item !== 'object') return undefined const grantee = item.grantee ?? item.peer?.handle ?? item.handle ?? item.home if (!grantee) return undefined const id = item.id ?? item.grant_id ?? item.relationship_id ?? item.relationshipId + const view = grantPersonView({ + grantee, + name: item.name ?? item.peer?.display_name ?? item.display_name ?? item.peer?.owner_display_name, + }) return { ...(id ? { id: String(id) } : {}), grantee: String(grantee), - ...(item.name || item.peer?.display_name || item.display_name ? { name: String(item.name ?? item.peer?.display_name ?? item.display_name) } : {}), + ...(view.label ? { name: view.label } : {}), + ...(view.home ? { home: view.home } : {}), + ...(view.member ? { member: view.member } : {}), ...(item.relationship_id || item.relationshipId ? { relationshipId: String(item.relationship_id ?? item.relationshipId) } : {}), ...(item.room_id || item.roomId ? { roomId: String(item.room_id ?? item.roomId) } : {}), } @@ -185,8 +226,8 @@ export function normalizeGrants(value) { } // Owner MCP + REST grant/invite helpers. Named methods so social.js never builds -// URLs or tool names inline; a later server can add list_grants / revoke_grant -// without touching the plugin's grant bookkeeping. +// URLs or tool names inline. Prefers list_bot_grants / revoke_bot_grant, then +// list_grants / revoke_grant, then REST / list_friends. export function createSocialApi({ server, token, fetch: fetchImpl = socialFetch, timeoutMs = CALL_TIMEOUT_MS, client } = {}) { const mcp = client ?? createMcpClient({ server, token, fetch: fetchImpl, timeoutMs }) const base = String(server ?? '').replace(/\/+$/, '') @@ -227,13 +268,14 @@ export function createSocialApi({ server, token, fetch: fetchImpl = socialFetch, ...(botId ? { bot_id: botId } : {}), ...(relationshipId ? { relationship_id: relationshipId } : {}), } - return normalizeGrants(await tryTool('list_grants', args, async () => { + const fromFriends = async () => { if (relationshipId && base) { try { return await rest('GET', `/v1/relationships/${encodeURIComponent(relationshipId)}/grants`) } catch { /* derive */ } } const overview = await mcp.callTool('list_friends', botId ? { bot_id: botId } : {}) return (overview?.friends ?? []).filter(item => item.status === 'active' || item.status === undefined) - })) + } + return normalizeGrants(await tryTool('list_bot_grants', args, () => tryTool('list_grants', args, fromFriends))) } async function revokeGrant({ grantId, relationshipId, botId } = {}) { @@ -242,7 +284,7 @@ export function createSocialApi({ server, token, fetch: fetchImpl = socialFetch, ...(relationshipId ? { relationship_id: relationshipId } : {}), ...(botId ? { bot_id: botId } : {}), } - return tryTool('revoke_grant', args, async () => { + const viaRestOrUnfriend = async () => { if (grantId && base) { try { return await rest('DELETE', `/v1/grants/${encodeURIComponent(grantId)}`) } catch { /* next */ } } @@ -252,7 +294,8 @@ export function createSocialApi({ server, token, fetch: fetchImpl = socialFetch, } if (!relationshipId) throw new BotSocialError('Bot Social revoke_grant: which grant?', 'invalid') return mcp.callTool('remove_friend', { relationship_id: relationshipId }) - }) + } + return tryTool('revoke_bot_grant', args, () => tryTool('revoke_grant', args, viaRestOrUnfriend)) } return { diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 6a58234fc..9e7bc5f64 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -30,7 +30,7 @@ import { chmodSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, wri import { readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, dirname, extname, isAbsolute, join, sep } from 'node:path' -import { createSocialApi, untrustedText, mapPool, GRANT_CONCURRENCY } from './mcp-client.js' +import { createSocialApi, untrustedText, mapPool, GRANT_CONCURRENCY, grantPersonView, memberHandleOf } from './mcp-client.js' import { createFaceRoomApi, isJoinCode, @@ -55,6 +55,7 @@ import { presentRemoteHandles, isHomeLevelHandle, isHiddenRemote, + revealHiddenIfPresent, WAITING_OTHERS, REJECT_NOTE, } from './hidden-remotes.js' @@ -88,12 +89,19 @@ export function approvalView(item, { localName } = {}) { const payload = item.message?.payload const text = draftText(payload && typeof payload === 'object' ? payload.text : payload) || draftText(item.summary) const kind = String(item.kind ?? '') + const viaComposer = item.via === 'composer' + || item.message?.via === 'composer' + || item.sender_kind === 'owner' + || item.sender_kind === 'human' + || item.from_kind === 'human' const named = typeof localName === 'function' ? localName(item.bot_id) : '' - const fromRaw = named - || (typeof item.from === 'string' ? item.from : item.from?.display_name) - || item.sender_name - || item.bot?.display_name - const from = cleanText(fromRaw) || (looksLikeBotId(item.bot_id) ? 'Bot' : cleanText(item.bot_id)) + const fromRaw = viaComposer + ? 'You' + : named + || (typeof item.from === 'string' ? item.from : item.from?.display_name) + || item.sender_name + || item.bot?.display_name + const from = viaComposer ? 'You' : (cleanText(fromRaw) || (looksLikeBotId(item.bot_id) ? 'Bot' : cleanText(item.bot_id))) return { id, kind, @@ -154,6 +162,13 @@ function handleFromOnboardError(body, displayName) { return typeof raw === 'string' && raw.trim() ? norm(raw) : homeHandleOf(displayName) } +export const ONBOARD_TRIES = 12 + +export function nextOnboardDisplayName(base, attempt) { + const name = String(base ?? 'FlyBot').trim() || 'FlyBot' + return attempt <= 1 ? name : `${name}${attempt}` +} + async function onboardError(response, displayName) { const status = response.status let body @@ -183,6 +198,36 @@ export async function onboard({ server, displayName, webhookUrl, fetch: fetchImp return { token: body.connector.token, home: { id: body.home.bot_id, handle: body.home.handle }, ownerId: body.owner_id, webhook: body.webhook ?? null } } +// First start: if @.home is taken, retry FlyBot2, FlyBot3, … rather than +// failing the plugin and asking the owner to edit YAML (#136). +export async function onboardHome({ server, displayName, webhookUrl, fetch: fetchImpl = globalThis.fetch } = {}) { + const base = String(displayName ?? 'FlyBot').trim() || 'FlyBot' + let last + for (let attempt = 1; attempt <= ONBOARD_TRIES; attempt++) { + const name = nextOnboardDisplayName(base, attempt) + try { + return { ...(await onboard({ server, displayName: name, webhookUrl, fetch: fetchImpl })), displayName: name } + } catch (error) { + last = error + if (error?.code !== 'handle_taken') throw error + } + } + throw last +} + +export function mapJoinInviteError(error) { + const msg = String(error?.message ?? error ?? '') + const code = String(error?.code ?? '') + const blob = `${code} ${msg}` + if (/relationship_exists/i.test(blob)) { + return Object.assign(new Error("You're already friends. That Bot is in Friends' Bots again."), { code: 'relationship_exists' }) + } + if (/invite is used|invite_used|already used|max_uses|used_up/i.test(blob)) { + return Object.assign(new Error('This invite was already used. Ask your friend for a new one.'), { code: 'invite_used' }) + } + return error +} + export const startFailureReason = (error) => String(error?.message ?? error ?? 'the plugin did not start').replace(/\s+/g, ' ').trim() || 'the plugin did not start' export function unavailableMessage(reason) { @@ -368,17 +413,25 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // ------------------------------------------------------------ settings (per friend home) // autoAccept[home]: messages with that friend go through without the approval hold - // (default on for every friendship). via[handle]: the local Bot whose Bot Social + // (default off for every new friendship). via[handle]: the local Bot whose Bot Social // identity carries the user's own chat with that friend's Bot. const settingsPath = config.settingsPath ?? (config.credentialsPath ? join(dirname(config.credentialsPath), 'flybot-social-settings.json') : undefined) - let settings = { autoAccept: {}, via: {}, hiddenRemotes: {} } - try { if (settingsPath) settings = { autoAccept: {}, via: {}, hiddenRemotes: {}, ...JSON.parse(readFileSync(settingsPath, 'utf8')) } } catch { /* none yet */ } + let settings = { autoAccept: {}, via: {}, hiddenRemotes: {}, inboundBots: {}, roomBots: {} } + try { if (settingsPath) settings = { autoAccept: {}, via: {}, hiddenRemotes: {}, inboundBots: {}, roomBots: {}, ...JSON.parse(readFileSync(settingsPath, 'utf8')) } } catch { /* none yet */ } settings.hiddenRemotes ??= {} + settings.inboundBots ??= {} + settings.roomBots ??= {} const saveSettings = () => { if (settingsPath) { try { writeCredentials(settingsPath, settings) } catch (error) { warn('settings not saved', error) } } } - // Unset: on (config.autoAccept: false makes it opt-in instead). + // Unset: off. config.autoAccept: true makes it opt-out instead. New friends start in hold (#140). // Config keys may be written with or without '@' and in any case. const approvalHomes = Object.fromEntries(Object.entries(config.approval?.homes ?? {}).map(([key, value]) => [norm(key), value])) - const autoAcceptOf = friendHome => settings.autoAccept[norm(friendHome)] ?? (approvalHomes[norm(friendHome)] ? approvalHomes[norm(friendHome)] === 'auto' : config.autoAccept !== false) + const autoAcceptOf = (friendHome) => { + const key = norm(friendHome) + const stored = settings.autoAccept[key] + if (stored !== undefined) return stored === true + if (approvalHomes[key]) return approvalHomes[key] === 'auto' + return config.autoAccept === true + } // ------------------------------------------------------------ members const memberOf = botId => flybot.fields.get(botId, 'member') @@ -483,7 +536,61 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // ------------------------------------------------------------ remote roster let remote = { homes: [] } let fetchedAt = 0 - function asRemoteHomes(entries) { + function rememberInbound(handle) { + const key = norm(handle) + if (!key || key === '@') return + settings.inboundBots ??= {} + if (settings.inboundBots[key]) return + settings.inboundBots[key] = true + saveSettings() + } + function markRoomRemote(handle, faceId) { + const key = norm(handle) + const room = String(faceId ?? '') + if (!key || key === '@' || !room) return + if (settings.inboundBots?.[key]) return + settings.roomBots ??= {} + const rec = settings.roomBots[key] ?? { rooms: {} } + if (rec.rooms?.[room]) return + settings.roomBots[key] = { rooms: { ...rec.rooms, [room]: true } } + saveSettings() + } + function dropRoomRemotes(faceId) { + const room = String(faceId ?? '') + if (!room || !settings.roomBots) return + let changed = false + for (const handle of Object.keys(settings.roomBots)) { + if (!settings.roomBots[handle]?.rooms?.[room]) continue + const rooms = { ...settings.roomBots[handle].rooms } + delete rooms[room] + // Keep the handle so a server leak after leave does not put the Bot back in Friends' Bots. + settings.roomBots[handle] = { rooms, leftover: true } + changed = true + } + if (changed) saveSettings() + } + function outgoingPeerHandles() { + const keys = new Set() + if (typeof flybot.grants?.of !== 'function') return keys + for (const bot of flybot.bot.list()) { + for (const grant of flybot.grants.of(bot.id) ?? []) { + if (grant.roomId) continue + const member = memberHandleOf(grant.member || grant.grantee) + if (!member || isHomeLevelHandle(member)) continue + keys.add(member) + } + } + return keys + } + function keepGrantedRemote(bot) { + const key = norm(bot.handle) + if (!key) return false + if (settings.inboundBots?.[key] || settings.inboundBots?.[bot.home]) return true + if (outgoingPeerHandles().has(key)) return false + if (settings.roomBots?.[key]) return false + return true + } + function mapRemoteHomes(entries) { return (entries ?? []).map(entry => { const homeHandle = norm(entry.home.handle) return { @@ -521,7 +628,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp try { if (home) { result = await client.callTool('list_remote_bots', { home: home.id }) - unfiltered = { homes: asRemoteHomes(result?.homes) } + const mapped = mapRemoteHomes(result?.homes) + unfiltered = { homes: mapped.map(entry => ({ ...entry, bots: entry.bots.filter(bot => keepGrantedRemote(bot)) })) } } else { // Per-bot identity (legacy): each active friend is its own "home" of one Bot. const overview = await client.callTool('list_friends', config.botId ? { bot_id: config.botId } : {}) @@ -538,9 +646,15 @@ export function createSocial(service, config = {}, { client: given, api: givenAp throw error } fetchedAt = now() + const rosterAt = fetchedAt if (home) void syncServerAutoApprove((result?.homes ?? [])) - // Grant listing is N HTTP calls; do not block Friends' Bots on it. - void syncOutgoingGrants().catch(error => warn('grants not synced', error)) + // Grant listing is N HTTP calls; do not block Friends' Bots on it. Re-filter + // once grants arrive so reverse-friends of our invites drop (#138). + void syncOutgoingGrants().then(() => { + if (fetchedAt !== rosterAt || !home || !result) return + const mapped = mapRemoteHomes(result.homes) + applyRemoteRoster({ homes: mapped.map(entry => ({ ...entry, bots: entry.bots.filter(bot => keepGrantedRemote(bot)) })) }, { source, ok: true }) + }).catch(error => warn('grants not synced', error)) const published = applyRemoteRoster(unfiltered, { source, ok: true }) if (published && typeof published.then === 'function') await published.catch(error => warn('remote chats not synced', error)) void leaveMissingRemotes().catch(error => warn('unpublished remotes not left', error)) @@ -671,12 +785,17 @@ export function createSocial(service, config = {}, { client: given, api: givenAp try { const listed = await api.listGrants({ botId: member.bot_id }) const roomScoped = (flybot.grants.of?.(bot.id) ?? []).filter(grant => grant.roomId) - const people = listed.filter(grant => !grant.roomId).map(grant => ({ - id: grant.id || grant.relationshipId || `${bot.id}:${grant.grantee}`, - grantee: grant.grantee, - ...(grant.name ? { name: grant.name } : {}), - ...(grant.relationshipId ? { relationshipId: grant.relationshipId } : {}), - })) + const people = listed.filter(grant => !grant.roomId).map(grant => { + const view = grantPersonView(grant) + return { + id: grant.id || grant.relationshipId || `${bot.id}:${grant.grantee}`, + grantee: grant.grantee, + ...(view.label ? { name: view.label } : grant.name ? { name: grant.name } : {}), + ...(view.home ? { home: view.home } : {}), + ...(view.member ? { member: view.member } : {}), + ...(grant.relationshipId ? { relationshipId: grant.relationshipId } : {}), + } + }) await flybot.grants.set(bot.id, [...people, ...roomScoped]) } catch (error) { warn(`${bot.name} grants not synced`, error) @@ -706,7 +825,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-accept (on unless the owner switched it off for this friend in the Friends panel). + // Auto-send (off unless the owner switched it on for this friend in Friends' Bots). if (autoAcceptOf(friendHome)) return 'auto' const homeSetting = approvalHomes[norm(friendHome)] if (homeSetting) return homeSetting @@ -1082,25 +1201,62 @@ export function createSocial(service, config = {}, { client: given, api: givenAp from = { from_bot_id: member.bot_id } joinedAs = { botId: bot.id, botName: bot.name, handle: norm(member.handle) } } - const joined = await client.callTool('join_invite', { code: value.replace(/^.*\/i\//, '').replace(/[?#].*$/, ''), ...from }) + const before = new Set(listedRemoteBots().map(bot => bot.handle)) + let joined + try { + joined = await client.callTool('join_invite', { code: value.replace(/^.*\/i\//, '').replace(/[?#].*$/, ''), ...from }) + } catch (error) { + const mapped = mapJoinInviteError(error) + if (mapped.code === 'relationship_exists' && home) { + try { + const result = await client.callTool('list_remote_bots', { home: home.id }) + const homes = mapRemoteHomes(result?.homes) + const next = revealHiddenIfPresent(settings, presentRemoteHandles(homes)) + if (next !== settings) { settings = next; saveSettings() } + fetchedAt = 0 + await remoteBots({ fresh: true }).catch(() => {}) + } catch (revealError) { + warn('could not unhide after relationship_exists', revealError) + } + } + throw mapped + } let status = joined.status if (joined.awaiting === 'your_owner_approval' && joined.approval_id) { await confirmedCall('decide_approval', { approval_id: joined.approval_id, decision: 'approved' }) status = 'pending_target' } fetchedAt = 0 - void remoteBots({ fresh: true }).catch(() => {}) - // A new friendship starts with auto-accept on (switchable per friend in Friends' Bots). - const inviterHome = joined.inviter?.handle ? homeOfPeer(joined.inviter.handle) : undefined - if (joined.relationship_id && (inviterHome === undefined || autoAcceptOf(inviterHome))) await serverAutoApprove(joined.relationship_id, true) - return { relationshipId: joined.relationship_id, status, inviter: joined.inviter?.handle ? norm(joined.inviter.handle) : undefined, inviterName: joined.inviter?.display_name, ...(joinedAs ? { joinedAs } : {}) } + const roster = await remoteBots({ fresh: true }).catch(() => remote) + const added = (roster?.homes ?? []).flatMap(entry => entry.bots).filter(bot => !before.has(bot.handle)) + for (const bot of added) rememberInbound(bot.handle) + const offered = joined.bot ?? joined.inviter?.bot ?? joined.offered_bot + if (offered?.handle) rememberInbound(offered.handle) + const inviterHome = joined.inviter?.handle ? homeOfPeer(joined.inviter.handle) : added[0]?.home + const inviterName = cleanText(joined.inviter?.display_name || joined.inviter?.owner_display_name || added[0]?.owner || added[0]?.homeName) + const inviterBotName = cleanText(offered?.display_name || offered?.name || (added.length === 1 ? added[0].name : '')) + // A new friendship starts with Auto-send off; only push server auto-approve if the owner already opted in. + if (joined.relationship_id && autoAcceptOf(inviterHome)) await serverAutoApprove(joined.relationship_id, true) + return { + relationshipId: joined.relationship_id, + status, + inviter: joined.inviter?.handle ? norm(joined.inviter.handle) : undefined, + inviterName: inviterName || undefined, + ...(inviterBotName ? { inviterBotName } : {}), + ...(joinedAs ? { joinedAs } : {}), + } })) disposers.push(flybot.api.handle('social/respond', async ({ relationshipId, decision } = {}) => { if (decision !== 'accept' && decision !== 'decline') throw new Error('decision is accept or decline') const result = await confirmedCall('respond_friend_request', { relationship_id: String(relationshipId ?? ''), decision, ...(decision === 'accept' ? { grant_capabilities: ['chat.public'] } : {}) }) if (decision === 'accept') { - void remoteBots({ fresh: true }).catch(() => {}) - await serverAutoApprove(String(relationshipId ?? ''), true) + const before = new Set(listedRemoteBots().map(bot => bot.handle)) + const roster = await remoteBots({ fresh: true }).catch(() => remote) + for (const bot of (roster?.homes ?? []).flatMap(entry => entry.bots)) { + if (!before.has(bot.handle)) rememberInbound(bot.handle) + } + const peerHome = roster?.homes?.find(entry => entry.bots?.length)?.home + if (autoAcceptOf(peerHome)) await serverAutoApprove(String(relationshipId ?? ''), true) } return { relationshipId, status: result?.status ?? (decision === 'accept' ? 'active' : 'declined') } })) @@ -1124,13 +1280,17 @@ export function createSocial(service, config = {}, { client: given, api: givenAp botId: bot.id, botName: bot.name, granted: hasPersonGrant(bot.id), - grants: grants.map(grant => ({ - id: grant.id, - grantee: grant.grantee, - name: grant.name, - relationshipId: grant.relationshipId, - rooms: roomsUsing(bot.id, grant.grantee), - })), + grants: grants.map(grant => { + const view = grantPersonView(grant) + return { + id: grant.id, + grantee: view.home || grant.home || grant.grantee, + name: view.label || grant.name, + home: view.home || grant.home, + relationshipId: grant.relationshipId, + rooms: roomsUsing(bot.id, grant.grantee), + } + }), rooms, } }, { read: true })) @@ -1248,10 +1408,11 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } return pick(openable) } - async function postChatNote(item, text) { + async function postChatNote(item, text, { chats: toChats = true } = {}) { for (const room of roomsForApproval(item)) { try { flybot.rooms.postNotice?.(room.id, text) } catch (error) { warn('approval notice not posted', error) } } + if (!toChats) return const chats = service.remote?.chats ?? flybot.remote?.chats const deliver = chats?.deliver if (!item?.to || !deliver) return @@ -1271,7 +1432,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp let changed = false for (const item of items ?? []) { if (settings.approvalNotices[item.id]) continue - await postChatNote(item, WAITING_OTHERS) + // Owner 1:1 chats use the Approve / Reject dock. Do not post a waiting bubble there (#140). + await postChatNote(item, WAITING_OTHERS, { chats: false }) settings.approvalNotices[item.id] = { at: now(), kind: 'waiting' } changed = true } @@ -1587,8 +1749,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } if (event.eventCursor) settings.faceRooms[snapshot.roomId].cursor = Math.max(settings.faceRooms[snapshot.roomId].cursor, event.eventCursor) } + const localHandles = new Set(locals.map(bot => bot.handle).filter(Boolean)) for (const member of snapshot.members ?? []) { const handle = norm(member.handle) + if (handle && !localHandles.has(handle)) markRoomRemote(handle, snapshot.roomId) const key = `join:${handle}` if (!handle || previousLive.has(handle) || joinedFromEvents.has(handle) || seen.includes(key)) continue seen = rememberEvent(seen, key) @@ -1654,6 +1818,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp || { faceRoomId: serverId, roomId: meta?.localRoomId, name: 'Face-to-face room', status: 'closed', members: [] }) if (meta?.localRoomId) await flybot.rooms.dropFace?.(meta.localRoomId).catch(() => {}) delete settings.faceRooms[serverId] + dropRoomRemotes(serverId) for (const handle of asBotsOf(meta)) { const left = botByHandle(handle) if (left) await releaseRoomOnlyIdentity(left.id) @@ -1727,11 +1892,14 @@ export function createSocial(service, config = {}, { client: given, api: givenAp await flybot.rooms.dropFace?.(meta.localRoomId).catch(() => {}) } delete settings.faceRooms[id] + dropRoomRemotes(id) for (const handle of asBotsOf(meta)) { const left = botByHandle(handle) if (left) await releaseRoomOnlyIdentity(left.id) } saveSettings() + fetchedAt = 0 + void remoteBots({ fresh: true }).catch(() => {}) armSocialPoll() return { roomId: meta?.localRoomId, faceRoomId: id, status: result?.status || 'left' } } @@ -1836,7 +2004,7 @@ export async function startSocial(service, config = {}) { } } if (credentials === undefined && explicit === undefined && config.server && config.onboard !== false) { - credentials = { ...(await onboard({ server: config.server, displayName: config.displayName ?? 'FlyBot', webhookUrl: config.webhookUrl, fetch: config.fetch })), server: serverKey(config.server) } + credentials = { ...(await onboardHome({ server: config.server, displayName: config.displayName ?? 'FlyBot', webhookUrl: config.webhookUrl, fetch: config.fetch })), server: serverKey(config.server) } if (config.credentialsPath) { // Another server's credentials stay, under their server, for switching back. const previous = readCredentials(config.credentialsPath) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index e8effd674..caaefc6dd 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -8,13 +8,13 @@ import { test } from 'node:test' import { apply as applyDsBot } from 'ds-bot' import { fakeContext } from './fake-ctx.js' import { apply as applySocial, inject, name } from '../index.js' -import { approvalView, createSocial, draftText, homeHandleOf, onboard, registerUnavailable, startSocial, unavailableMessage } from '../src/social.js' -import { createMcpClient, createSocialApi, untrustedText, mapPool, socialHttpAgent, fetchOptions } from '../src/mcp-client.js' +import { approvalView, createSocial, draftText, homeHandleOf, onboard, onboardHome, nextOnboardDisplayName, mapJoinInviteError, registerUnavailable, startSocial, unavailableMessage } from '../src/social.js' +import { createMcpClient, createSocialApi, untrustedText, mapPool, socialHttpAgent, fetchOptions, grantPersonView, memberHandleOf, personHomeOf, normalizeGrant } from '../src/mcp-client.js' import { encodeRoomShare } from '../../../core/src/shared/room-share.js' import { encodeFaceHuman } from '../src/face-rooms.js' import { hideRemoteInSettings, forgetHiddenIfGone, filterHiddenHomes, presentRemoteHandles, - HIDE_GONE_FETCHES, isHomeLevelHandle, isHiddenRemote, clearHiddenRemote, homeOfHiddenHandle, + HIDE_GONE_FETCHES, isHomeLevelHandle, isHiddenRemote, clearHiddenRemote, homeOfHiddenHandle, revealHiddenIfPresent, approvalsForSession, chatApprovalTitle, WAITING_OWNER, WAITING_OTHERS, REJECT_NOTE, } from '../src/hidden-remotes.js' @@ -29,9 +29,13 @@ function mockServer({ token = 'owner-token', friends = true } = {}) { const uploads = new Map() const tools = { create_invite: a => ({ code: 'INV123', url: 'https://s/i/INV123', status: 'active', expires_at: '2026-10-21T00:00:00Z', bot: a.bot_id, auto_accept: a.auto_accept }), - join_invite: a => (a.code === 'NEEDSOK' - ? { relationship_id: 'r9', status: 'pending_requester_owner', awaiting: 'your_owner_approval', approval_id: 'apr9', inviter: { handle: 'eve.home', display_name: 'Eve' } } - : { relationship_id: 'r8', status: 'active', awaiting: null, inviter: { handle: 'bob.home', display_name: 'Bob' } }), + join_invite: a => { + if (a.code === 'USED') throw Object.assign(new Error('Bot Social join_invite: invite is used'), { code: 'invite_used' }) + if (a.code === 'EXISTS') throw Object.assign(new Error('Bot Social join_invite: relationship_exists'), { code: 'relationship_exists' }) + return a.code === 'NEEDSOK' + ? { relationship_id: 'r9', status: 'pending_requester_owner', awaiting: 'your_owner_approval', approval_id: 'apr9', inviter: { handle: 'eve.home', display_name: 'Eve' }, bot: { handle: '@helper.eve.home', display_name: 'Helper' } } + : { relationship_id: 'r8', status: 'active', awaiting: null, inviter: { handle: 'bob.home', display_name: 'Bob' }, bot: { handle: '@coder.bob.home', display_name: 'Coder' } } + }, respond_friend_request: a => (a.confirm_code === undefined ? { status: 'confirmation_required', confirm_code: 'C0DE01' } : a.confirm_code === 'C0DE01' ? { relationship_id: a.relationship_id, status: a.decision === 'accept' ? 'active' : 'declined' } : { error: 'bad code' }), decide_approval: a => (a.confirm_code === undefined ? { status: 'confirmation_required', confirm_code: 'C0DE02' } : { approval_id: a.approval_id, decision: a.decision, status: a.decision }), list_approvals: () => ({ approvals: [] }), @@ -445,7 +449,7 @@ test('add friend from the UI: invite link, join by link, and answer requests (ow const chiefId = [...server.members.values()].find(m => m.handle === '@chief.danyang.home').bot_id const writerId = [...server.members.values()].find(m => m.handle === '@writer.danyang.home').bot_id const joined = (await call('flybot/social/join-invite', { code: ' https://other.example/i/ABC?x=1 ' })).value - assert.deepEqual(joined, { relationshipId: 'r8', status: 'active', inviter: '@bob.home', inviterName: 'Bob', joinedAs: { botId: 'a', botName: 'Chief', handle: '@chief.danyang.home' } }) + assert.deepEqual(joined, { relationshipId: 'r8', status: 'active', inviter: '@bob.home', inviterName: 'Bob', inviterBotName: 'Coder', joinedAs: { botId: 'a', botName: 'Chief', handle: '@chief.danyang.home' } }) assert.deepEqual(server.calls.find(c => c.name === 'join_invite').args, { code: 'ABC', from_bot_id: chiefId }) const asWriter = (await call('flybot/social/join-invite', { code: 'DEF', botId: 'b' })).value assert.equal(asWriter.joinedAs.handle, '@writer.danyang.home') @@ -696,8 +700,7 @@ test('start failure registers social endpoints instead of Unknown FlyBot endpoin assert.equal(result.ok, false) assert.equal(result.error.code, 'bot/refused') assert.match(result.error.message, /^Social is not running:/) - assert.match(result.error.message, /@danyang\.home is taken/) - assert.match(result.error.message, /displayName/) + assert.match(result.error.message, /is taken/) assert.doesNotMatch(result.error.message, /Unknown FlyBot endpoint/) const status = await call('flybot/social/status', {}) assert.equal(status.ok, false) @@ -874,7 +877,7 @@ test('a friendship scoped to one Bot: the roster shows only the granted Bots and const server = mockServer() // Bob invited us to Coder only and we joined as Chief: Bob's Writer is not listed by the server. server.tools.list_remote_bots = () => ({ homes: [{ relationship_id: 'r1', home: { bot_id: 'h2', handle: '@bob.home', display_name: 'Bob', owner_display_name: 'Bob' }, bots: [{ bot_id: 'x1', handle: '@coder.bob.home', display_name: 'Coder' }], scope: 'member', via: ['@chief.danyang.home'] }] }) - const social = createSocial(ctx.flybot, { inboxPollMs: 0 }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } }) + const social = createSocial(ctx.flybot, { inboxPollMs: 0, autoAccept: true }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } }) t.after(() => social.dispose()) await grantTeam(ctx.flybot) await social.registerAll() @@ -977,6 +980,14 @@ test('approvalView: outbound draft text is sanitized, friend requests stay out', assert.equal(raw.from, 'Bot') assert.equal(raw.toName, 'the room') assert.doesNotMatch(raw.from, /bot_01/) + const yours = approvalView({ + approval_id: 'apr-you', + kind: 'outbound_message', + via: 'composer', + bot_id: 'm1', + message: { to: { handle: '@chief.bob.home', display_name: 'Chief' }, payload: { text: 'hi' }, via: 'composer' }, + }, { localName: () => 'Helper' }) + assert.equal(yours.from, 'You') }) test('Friends\' Bots (#69): a pending approval shows a badge and a one-line jump row', () => { @@ -1087,7 +1098,7 @@ test('a paused conversation explains the pause and names the console, without re } return base(url, init) } - const social = createSocial(ctx.flybot, { server: 'https://social.test', inboxPollMs: 0 }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } }) + const social = createSocial(ctx.flybot, { server: 'https://social.test', inboxPollMs: 0, autoAccept: true }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } }) t.after(() => social.dispose()) await grantTeam(ctx.flybot) await social.registerAll() @@ -2455,6 +2466,8 @@ test('#90: hide helpers persist a handle and forget it only after two successful assert.equal(cleared.hiddenRemotes['@coder.bob.home'], undefined) const reshared = filterHiddenHomes(homes, gone) assert.deepEqual(reshared[0].bots.map(b => b.handle), ['@coder.bob.home', '@writer.bob.home']) + const revealed = revealHiddenIfPresent(settings, ['@coder.bob.home']) + assert.equal(revealed.hiddenRemotes['@coder.bob.home'], undefined) }) test('#90: hide a friend\'s Bot updates the list, leaves rooms, and does not unfriend', async (t) => { @@ -3065,11 +3078,8 @@ test('#123: approval waiting notice opens a friend Bot 1:1 instead of delivering const items = await social.pendingApprovals({ fresh: true }) assert.equal(items.length, 1) assert.equal(items[0].to, '@boxb.home') - assert.equal(notes.length, 1) - assert.equal(notes[0].handle, '@chief.boxb.home') - assert.equal(notes[0].text, WAITING_OTHERS) - assert.equal(opened.some(member => member.handle === '@chief.boxb.home'), true) - assert.equal(notes.some(note => note.handle === '@boxb.home'), false) + assert.equal(notes.length, 0, 'owner 1:1 does not get a waiting-for-approval bubble') + assert.equal(opened.length, 0) }) test('#123: approval waiting notice skips a home handle with no friend Bot and does not throw', async (t) => { @@ -3108,3 +3118,223 @@ test('#123: approval waiting notice skips a home handle with no friend Bot and d assert.equal(notes.length, 0) }) +test('#136: onboard retries a taken display name and then starts', async () => { + assert.equal(nextOnboardDisplayName('FlyBot', 1), 'FlyBot') + assert.equal(nextOnboardDisplayName('FlyBot', 2), 'FlyBot2') + const names = [] + const result = await onboardHome({ + server: 'https://social.test', + displayName: 'FlyBot', + fetch: async (_url, init) => { + const name = JSON.parse(init.body).display_name + names.push(name) + if (name === 'FlyBot') { + return new Response(JSON.stringify({ error: 'handle_taken', handle: 'flybot.home' }), { status: 409 }) + } + return new Response(JSON.stringify({ + owner_id: 'o1', connector: { token: 't' }, home: { bot_id: 'h1', handle: '@flybot2.home' }, webhook: null, + }), { status: 201 }) + }, + }) + assert.deepEqual(names, ['FlyBot', 'FlyBot2']) + assert.equal(result.home.handle, '@flybot2.home') + assert.equal(result.displayName, 'FlyBot2') +}) + +test('#136 / #138: grant labels use the person home, not a raw member handle', () => { + assert.equal(memberHandleOf('Chief@chief.selfb1935.home'), '@chief.selfb1935.home') + assert.equal(personHomeOf('Chief@chief.selfb1935.home'), '@selfb1935.home') + assert.equal(personHomeOf('@chief.selfb1935.home'), '@selfb1935.home') + assert.equal(personHomeOf('@selfb1935.home'), '@selfb1935.home') + const view = grantPersonView({ grantee: 'Chief@chief.selfb1935.home', name: 'Chief@chief.selfb1935.home' }) + assert.equal(view.home, '@selfb1935.home') + assert.equal(view.label, 'Selfb1935 (@selfb1935.home)') + const named = grantPersonView({ grantee: '@bob.home', name: 'Bob' }) + assert.equal(named.label, 'Bob (@bob.home)') + const normalized = normalizeGrant({ grantee: 'Chief@chief.bob.home', display_name: 'Chief@chief.bob.home' }) + assert.equal(normalized.home, '@bob.home') + assert.equal(normalized.member, '@chief.bob.home') +}) + +test('#138: Friends\' Bots drops the joiner\'s Bot from an outgoing invite', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = mockServer() + server.tools.list_bot_grants = () => ({ + grants: [{ id: 'g-rev', grantee: 'Chief@chief.bob.home', name: 'Chief@chief.bob.home', relationship_id: 'r1' }], + }) + server.tools.list_remote_bots = () => ({ + homes: [{ + relationship_id: 'r1', + home: { bot_id: 'h2', handle: '@bob.home', display_name: 'Bob', owner_display_name: 'Bob' }, + bots: [ + { bot_id: 'x1', handle: '@chief.bob.home', display_name: 'Chief' }, + { bot_id: 'x2', handle: '@coder.bob.home', display_name: 'Coder' }, + ], + }], + }) + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0 }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + const listed = (await call('flybot/social/remote-bots', { fresh: true })).value + assert.equal(listed.homes[0].bots.some(bot => bot.handle === '@chief.bob.home'), false, 'the Bot that joined our invite is not granted to us') + assert.equal(listed.homes[0].bots.some(bot => bot.handle === '@coder.bob.home'), true) + const access = (await call('flybot/social/access', { botId: 'a' })).value + assert.ok(access.grants.some(g => g.name === 'Bob (@bob.home)' || g.home === '@bob.home')) + assert.equal(access.grants.some(g => /Chief@chief/.test(g.name || '') || /Chief@chief/.test(g.grantee || '')), false) +}) + +test('#138: joining an invite marks that Bot inbound and does not auto-send', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = mockServer() + const social = createSocial(ctx.flybot, { inboxPollMs: 0 }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + const joined = (await call('flybot/social/join-invite', { code: 'ABC' })).value + assert.equal(joined.inviterBotName, 'Coder') + assert.equal(joined.inviterName, 'Bob') + const listed = (await call('flybot/social/remote-bots', { fresh: true })).value + assert.equal(listed.homes[0].bots.some(bot => bot.handle === '@coder.bob.home'), true) + const settings = (await call('flybot/social/settings', {})).value + assert.equal(settings.autoAccept['@bob.home'], false) + assert.equal(server.calls.some(c => c.name === 'set_auto_approve' && c.args.auto_approve_chat === true), false) +}) + +test('#139: join errors are English and relationship_exists unhides', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer() + const settingsPath = join(home, 'flybot-social-settings.json') + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + await call('flybot/social/remote-bots', { fresh: true }) + await call('flybot/social/hide-remote', { handle: '@coder.bob.home' }) + const used = await call('flybot/social/join-invite', { code: 'USED' }) + assert.equal(used.ok, false) + assert.match(used.error.message, /already used/i) + assert.doesNotMatch(used.error.message, /invite is used/) + const exists = await call('flybot/social/join-invite', { code: 'EXISTS' }) + assert.equal(exists.ok, false) + assert.match(exists.error.message, /already friends/i) + assert.doesNotMatch(exists.error.message, /relationship_exists/) + const again = (await call('flybot/social/remote-bots', { fresh: true })).value + assert.equal(again.homes[0].bots.some(bot => bot.handle === '@coder.bob.home'), true) + assert.equal(mapJoinInviteError(Object.assign(new Error('relationship_exists'), { code: 'relationship_exists' })).message.includes('already friends'), true) +}) + +test('#138: leaving a face room drops room-only remotes from Friends\' Bots', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = withFaceRooms(mockServer()) + server.tools.list_remote_bots = () => ({ + homes: [{ + home: { bot_id: 'h2', handle: '@bob.home', display_name: 'Bob' }, + bots: [{ bot_id: 'x1', handle: '@chief.bob.home', display_name: 'Chief' }], + }], + }) + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await social.registerAll() + const joined = (await call('flybot/social/face-join', { code: '123456', botId: 'a' })).value + const room = server.face.rooms.get(joined.faceRoomId) + room.members.push({ bot_id: 'bot_bob', handle: '@chief.bob.home', display_name: 'Chief', owner_name: 'Bob', published: false }) + await call('flybot/social/face-rooms', { poll: true }) + const during = (await call('flybot/social/remote-bots', { fresh: true })).value + assert.equal(during.homes.flatMap(entry => entry.bots).some(bot => bot.handle === '@chief.bob.home'), false, 'room-only remotes stay out of Friends\' Bots') + await call('flybot/social/face-leave', { faceRoomId: joined.faceRoomId }) + const after = (await call('flybot/social/remote-bots', { fresh: true })).value + assert.equal(after.homes.flatMap(entry => entry.bots).some(bot => bot.handle === '@chief.bob.home'), false) +}) + +test('#140: new friends default Auto-send off; settings report false', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = mockServer() + const social = createSocial(ctx.flybot, { inboxPollMs: 0 }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await social.remoteBots({ fresh: true }) + const settings = (await call('flybot/social/settings', {})).value + assert.equal(settings.autoAccept['@bob.home'], false) + assert.equal(social.approvalFor('a', '@bob.home'), 'hold') +}) + +test('createSocialApi prefers list_bot_grants', async () => { + const server = mockServer() + server.tools.list_bot_grants = () => ({ grants: [{ id: 'g-bot', grantee: '@carol.home', name: 'Carol' }] }) + const api = createSocialApi({ server: 'https://s', token: 'owner-token', fetch: server.fetch }) + const listed = await api.listGrants({ botId: 'm1' }) + assert.ok(listed.some(g => g.grantee === '@carol.home')) + 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) + 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) +}) + +test('#139: Remove asks once; cancelling leaves the Bot', () => { + const asked = [] + const hidden = [] + const { walk, pluginOf, sandbox, entries } = 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 remove = tree.find(n => n.props['data-flybot'] === 'remove-friend-bot') + remove.props.onClick({ stopPropagation() {} }) + assert.equal(asked.length, 1) + assert.match(asked[0], /Remove Helper\?/) + assert.equal(hidden.length, 0) +}) + +test('#138: join success copy names the person and the Bot', () => { + const src = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + assert.match(src, /shared \$\{shared\} with you/) + assert.match(src, /Talk through /) + assert.match(src, /not shared unless you invite them to it/) +}) + +