diff --git a/changelog.d/506-invite-copy-leftovers.fixed.md b/changelog.d/506-invite-copy-leftovers.fixed.md new file mode 100644 index 00000000..343aa01a --- /dev/null +++ b/changelog.d/506-invite-copy-leftovers.fixed.md @@ -0,0 +1 @@ +Host and zh copy that still said **Bot settings → Invite to this Bot** after #499/#504 now point at **Bot settings → Sharing → Copy invite link**. diff --git a/core/client.js b/core/client.js index f0c2ae4e..3944f1b9 100644 --- a/core/client.js +++ b/core/client.js @@ -121,7 +121,7 @@ var ZH = { "Invite link for {name} copied. Send it to your friend; once they accept, create the group again.": "{name} 的邀请链接已复制。发给好友,对方接受后再重新建群。", "Invite link": "邀请链接", "Could not copy the invite link for {name}. Copy the link below and send it to your friend; once they accept, create the group again.": "无法自动复制 {name} 的邀请链接。请手动复制下面的链接发给好友,对方接受后再重新建群。", - "Could not create the invite. Use Bot settings → Invite to this Bot.": "无法创建邀请。请在 Bot 设置里使用 Invite to this Bot。", + "Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot.": "无法创建邀请。请在 Bot 设置 → Sharing 里使用 Copy invite link for this Bot。", "Add friend": "添加好友", "Friend requests": "好友请求", "Accept or decline incoming requests.": "接受或拒绝收到的请求。", @@ -11484,7 +11484,7 @@ function NewChat({ mode, roster, actions }) { if (!link) throw new Error("no link"); } catch { setInviteLink(""); - setFixNote(t("Could not create the invite. Use Bot settings → Invite to this Bot.")); + setFixNote(t("Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot.")); return; } if (await copiedToClipboard(link)) { diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index c866ea35..f1bae515 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -84,7 +84,7 @@ const ZH = { 'Invite link for {name} copied. Send it to your friend; once they accept, create the group again.': '{name} 的邀请链接已复制。发给好友,对方接受后再重新建群。', 'Invite link': '邀请链接', 'Could not copy the invite link for {name}. Copy the link below and send it to your friend; once they accept, create the group again.': '无法自动复制 {name} 的邀请链接。请手动复制下面的链接发给好友,对方接受后再重新建群。', - 'Could not create the invite. Use Bot settings → Invite to this Bot.': '无法创建邀请。请在 Bot 设置里使用 Invite to this Bot。', + 'Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot.': '无法创建邀请。请在 Bot 设置 → Sharing 里使用 Copy invite link for this Bot。', 'Add friend': '添加好友', 'Friend requests': '好友请求', 'Accept or decline incoming requests.': '接受或拒绝收到的请求。', diff --git a/core/src/client/new-chat.js b/core/src/client/new-chat.js index 40ca4c12..b4629abd 100644 --- a/core/src/client/new-chat.js +++ b/core/src/client/new-chat.js @@ -74,7 +74,7 @@ export function NewChat({ mode, roster, actions }) { if (!link) throw new Error('no link') } catch { setInviteLink('') - setFixNote(t('Could not create the invite. Use Bot settings → Invite to this Bot.')) + setFixNote(t('Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot.')) return } if (await copiedToClipboard(link)) { diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 00ada390..a27cffed 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -946,7 +946,7 @@ export function install(rt) { list: () => team().map(view), find: name => view(rt.findBot?.(name)), isRemote, - // Whether this Bot has at least one person-grant (Invite to this Bot was accepted). + // Whether this Bot has at least one person-grant (Sharing → Copy invite link was accepted). hasGrant: (id, peer, roomId) => rt.hasGrant?.(id, peer, roomId) === true, hasAnyGrant: id => rt.hasAnyGrant?.(id) === true, of: sessionId => view(rt.botOf(rt.selfOf(sessionId))), diff --git a/core/src/host/reach.js b/core/src/host/reach.js index 48acaa0d..041fe9b4 100644 --- a/core/src/host/reach.js +++ b/core/src/host/reach.js @@ -7,7 +7,7 @@ // // There is no global "published" flag. Every access is an independent per-Bot GRANT // (one of my Bots → one grantee; optionally scoped to a group). Default deny, Chief -// included. A grant is created when someone accepts "Invite to this Bot", or implicitly +// included. A grant is created when someone accepts a Sharing → Copy invite link, or implicitly // when the owner brings their own Bot into a face-to-face room by its 6-digit number // (`viaCode: true`, this room only, while a member). // diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 59d1a9ed..f3e585ef 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -485,7 +485,7 @@ export function install(rt) { const names = localBots.map(bot => bot.name).join(', ') const friends = [...new Set(remotes.map(p => p.owner || p.name))].join(', ') || 'a friend' const many = localBots.length > 1 - const message = `${names} ${many ? 'are' : 'is'} not shared with ${friends}, so ${many ? 'they' : 'it'} cannot be in a group with their Bot: the group's messages go to ${friends}'s home. Share ${many ? 'them' : 'it'} with ${friends} (Bot settings → Invite to this Bot), or leave ${many ? 'them' : 'it'} out.` + const message = `${names} ${many ? 'are' : 'is'} not shared with ${friends}, so ${many ? 'they' : 'it'} cannot be in a group with their Bot: the group's messages go to ${friends}'s home. Share ${many ? 'them' : 'it'} with ${friends} (Bot settings → Sharing → Copy invite link for ${names}), or leave ${many ? 'them' : 'it'} out.` return Object.assign(forbidden(message), { details: { reason: 'ungranted-local', bots: localBots, remotes } }) } diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 257065e8..38d3df84 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -935,7 +935,7 @@ test('an ungranted local Bot refused next to a friend\'s Bot: the error names it await assert.rejects(async () => rt.applyParticipants({ id: 'new', members: [], participants: [], createdAt: 1 }, drafts, actorA), (error) => { assert.equal(error.code, 'bot/forbidden') assert.match(error.message, /^Writer A is not shared with Bob, so it cannot be in a group with their Bot/) - assert.match(error.message, /Invite to this Bot/) + assert.match(error.message, /Sharing → Copy invite link for Writer A/) assert.match(error.message, /leave it out/) assert.deepEqual(error.details, { reason: 'ungranted-local', diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index 499910cc..4860c67e 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -31,7 +31,7 @@ export interface BotView { name: string role?: string isMain: boolean - /** True when this Bot has at least one person-grant (Invite to this Bot was accepted). */ + /** True when this Bot has at least one person-grant (Sharing → Copy invite link was accepted). */ granted: boolean createdBy: BotId | null /** Text prompt.js appends to this Bot's system prompt (bot.setSystemPrompt). */ diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 5d54cdf2..d5e778d5 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -10,7 +10,7 @@ The social plugin talks to a [Bot Social](https://github.com/Birfy/bot-social) s 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, after New group chat; `label` / `hint` use the same `t()` map). **Face-to-face room** and **Add friend** are those rows (copying an invite and your own handle live inside Add friend); the social strip has no **+ Add** button. **Face-to-face room** opens the same screen (pick one or more local Bots, six-digit number, live waiting room that shows the code, **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; **Share with a friend** (#500) lists current friends who do not have this Bot yet and grants it in one click (Bot Social `grant_bot_to_friend`, companion Birfy/bot-social#34) without an invite link; **People with 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, after New group chat; `label` / `hint` use the same `t()` map). **Face-to-face room** and **Add friend** are those rows (copying an invite and your own handle live inside Add friend); the social strip has no **+ Add** button. **Face-to-face room** opens the same screen (pick one or more local Bots, six-digit number, live waiting room that shows the code, **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). **Sharing → Copy invite link** creates a per-Bot grant when the friend accepts; **Share with a friend** (#500) lists current friends who do not have this Bot yet and grants it in one click (Bot Social `grant_bot_to_friend`, companion Birfy/bot-social#34) without an invite link; **People with 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 @@ -34,7 +34,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers | `plugins/social/src/face-rooms.js` | Face-to-face REST client (join/list/get/start/leave/messages), error mapping, event rendering, peer-string sanitization. Auto-send is MCP in `social.js`. | | `plugins/social/src/presence.js` | "Name is thinking…" for friends' Bots (#297): sends `set_presence` for my Bots' turns that answer a friend or run in an open face room (`turn.start` / `turn.end`, heartbeat), polls `list_presence` only while a social chat asks (`social/typing`), clears a line when that Bot's message arrives. Gated on the `presence` feature. | | `plugins/social/src/face-title.js` | Face-to-face room titles (`Face-to-face · Alice, Bob · 482913`). Ships in the plugin so a packaged install can import (#153); core keeps its own copy for hosted rooms. | -| `plugins/social/client.js` | Settings → **Bot Social** (`SocialServerSettings`, slot `flybot.settings.page`); `PeerThinking` dock line (`social-thinking`, order 60: one `bt-shimmer` line `Name is thinking…`). Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face room / Add friend (own handle line, per-Bot **Copy invite link**); in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid (6-digit code, then Leave / Enter chat); Bot settings Invite / Share with a friend / People with access (inside the host's titled **Sharing** card, so `BotSocialDetails` draws no title of its own, #406), or Auto-send + Remove this Bot on a friend's Bot. | +| `plugins/social/client.js` | Settings → **Bot Social** (`SocialServerSettings`, slot `flybot.settings.page`); `PeerThinking` dock line (`social-thinking`, order 60: one `bt-shimmer` line `Name is thinking…`). Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face room / Add friend (own handle line, per-Bot **Copy invite link**); in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid (6-digit code, then Leave / Enter chat); Bot settings Sharing (Copy invite link) / Share with a friend / People with access (inside the host's titled **Sharing** card, so `BotSocialDetails` draws no title of its own, #406), or Auto-send + Remove this Bot on a friend's Bot. | ## Entry points @@ -45,7 +45,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers 1. Social code stays inside `plugins/social/`. Runtime sources (`index.js`, `client.js`, `src/`) import only files in that package, Node builtins, or declared dependencies (`ds-bot`). They must not reach `core/` or another plugin by relative path: that path is missing after `plugin add …&path:/plugins/social` (#153). Tests may import from `core/` for fixtures. `scripts/lint-plugins.mjs` fails the leak and packs the plugin then imports it. 2. Uses documented plugin APIs only. 3. A server policy pause (`conversation_paused`: loop suspected between the Bots, emergency stop, or an older server's turn budget) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to *"Bot Social paused that chat (reason). Send your message again: it starts a new chat."* (structured: `code: 'conversation_paused'`, `paused`, `resend`), because `send_as_member` opens a fresh conversation for the pair's next message. It never points to the Bot Social website (`/ui`), which a FlyBot owner cannot sign in to, and never names Extend or turns (there is no turn limit, owner decision 2026-10-19, Bot Social #28; see below) and no other member Bot is tried for it. Other failures end in *Try again.*, never in a console pointer. 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. The refusal says it the right way round and names the one setting that exists (#273): the sending Bot is not shared with that friend (*"Chief is not shared with that friend, so it cannot message their Bots. To allow it, share Chief with them in its settings → Sharing."*), or the target is not a Bot a friend shared with you; `message_friend_bot` tells the model to relay that reason as written. -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; **Share with a friend** (`social/share-friends` + `social/share-with-friend`, via `grantBotToFriend` / `grant_bot_to_friend`) is the same grant for someone who is already a friend, with no invite code. Joining does not grant my talk-through Bot back. Join offers **Talk through** only when I have more than one Bot (default Chief; tooltip: not shared unless I invite them to it, #228). `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`. +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 **Sharing → Copy invite link** is the grant the other way; **Share with a friend** (`social/share-friends` + `social/share-with-friend`, via `grantBotToFriend` / `grant_bot_to_friend`) is the same grant for someone who is already a friend, with no invite code. Joining does not grant my talk-through Bot back. Join offers **Talk through** only when I have more than one Bot (default Chief; tooltip: not shared unless I invite them to it, #228). `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, 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. Add friend shows `You are @your.home` (#181). 6. Face-to-face join (#83, #92, #158) is a waiting room, not an instant chat: the owner picks local Bots, types the 6-digit number, then waits with the same code shown large until someone **Enter chat**s. Copy never says there is no preview. 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, including `joinCode`) and draws one tile per person (#457: `FacePersonTile`, the person's name, `Me (you)` for the owner, with the Bots they brought underneath; the owner's tile has a tiny accent dot); **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). Owner-typed `FLYBOT-FACE-HUMAN` lines stay on that card until Approve / Always approve / Reject unless face **Auto-send** is on; the card and chat never show the raw envelope (#159). Face-room **Auto-send** is one switch per Bot of mine in the room (#457, `Auto-send Chief → room`, `social/face-auto { botId, on }`, kept in this home's `settings.faceAutoSend`, off by default): when that Bot's line comes back `pending_approval` and Bot Social's hold names no DLP reason, the host approves it at once (`decide_approval`, the switch is the owner's consent), so the room sees it; a flagged line and other Bots' lines still wait for the owner. The older account-wide `auto_approve_face_rooms` (`whoami` / MCP `set_auto_approve`) reads as every switch on; the first per-Bot change keeps the others on here and turns the account-wide one off (a `confirmation_required` answer is retried with `confirm_code` in the same click; the UI never asks for a 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. A number nobody else is using opens no room (#458): `social/face-join` without `start` answers `{ nobody: true, faceRoomId }` when the server's room holds only this home's Bots, and the host keeps that fresh candidate off the sidebar and Past rooms (an in-memory hold, dropped on the next poll after 2 minutes). The dialog shows "Nobody is using … yet." with **Cancel** (`social/face-leave { faceRoomId }` leaves it on Bot Social and releases a new room-only identity) and **Start a room** (`start: true` adopts the held room through `GET`, no second create). A room nobody from another home ever joined leaves no Past rooms entry and no kept transcript, whether the owner left it or the joining window ran out (the host then leaves it on Bot Social). The room stays open while two or more homes are in it and closes here only when this home is the last; the closed room's dock reads "… left the room. Room closed." with one **Close**. Leave and Close go back to the last non-face chat this browser showed (else the Main Bot) and close the details panel. Countdowns use the server's `expires_at` corrected by the Bot Social clock offset (from the `Date` header, applied only at 3 s or more) and catch up on `visibilitychange`. Join stays disabled until 6 digits, an outside click closes the dialog like Escape, and Enter chat steps aside while Leave asks. Core's sidebar leads a face-room row with its number and keeps face-room turns out of a Bot row's group-work line. 7. Every import in shipped sources stays inside the plugin package: an installed plugin cannot reach into other packages' files (a repo-relative `../..` into `core/` breaks the installed bundle). diff --git a/docs/subsystems/team.md b/docs/subsystems/team.md index 7ebf0203..a7bcec49 100644 --- a/docs/subsystems/team.md +++ b/docs/subsystems/team.md @@ -72,7 +72,7 @@ Central authorization in `core/src/host/authz.js`, installed after member-auth: Typed participants on a native room, in `core/src/host/room-membership.js`, installed after groups: - A room may carry `participants`: `{ kind: 'human'|'bot', id, role: 'admin'|'member', displayName, joinedSeq, joinedAt, leftSeq?, leftAt?, invitedBy? }`. A **granted remote Bot** is the same kind with `remote: true`, `handle`, `ownerHome`, `ownerName`, `isChief?`. `room.members` stays the current *local* bot ids so existing single-owner groups keep their shape. Rooms without `participants` stay legacy: the owner is implicit and `who: 'User'` is that owner. -- Across owners, a *local* agent must have a **person-grant** (#90; Invite to this Bot was accepted) or have joined this room by its room code (#83, this room only). An ungranted Bot cannot be added, and cannot be in a room that has another owner or a friend's Bot (enforced on the host, not only hidden in the UI; revoking the grant takes it out at once). A friend's Bot listed by `@flybot/social` `social/remote-bots` (Bots granted to me) may be added; ungranted specialists stay owner-private. **Why the rule stands for a group (not only a 1:1 chat):** a group that holds a friend's Bot is projected to that friend's home. `fanoutRoomLine` sends its lines on, and the replies of every local Bot that may speak there; the friend's Bot's lines come back into the room that the local Bots read. A local Bot that is not shared with that friend would either be silently cut off from the friend (its lines are filtered, `mayShareRoom`) or be exposed to the friend's Bot's text — so the host refuses instead of half-including it, and the Bot Social server would refuse its messages anyway (no grant on its side). The refusal is not a dead end: it names the Bot and the friend (`details.reason: 'ungranted-local'`, `bots`, `remotes`, Bot names from local records, friend names through `cleanPeerName`) and the new-chat card offers **Copy invite link for {Bot}** (the Bot's own invite; the grant exists only after the friend accepts it) or **Leave {Bot} out**. Nothing is granted by the click, and no room-scoped grant is made for a plain group. +- Across owners, a *local* agent must have a **person-grant** (#90; Sharing → Copy invite link was accepted) or have joined this room by its room code (#83, this room only). An ungranted Bot cannot be added, and cannot be in a room that has another owner or a friend's Bot (enforced on the host, not only hidden in the UI; revoking the grant takes it out at once). A friend's Bot listed by `@flybot/social` `social/remote-bots` (Bots granted to me) may be added; ungranted specialists stay owner-private. **Why the rule stands for a group (not only a 1:1 chat):** a group that holds a friend's Bot is projected to that friend's home. `fanoutRoomLine` sends its lines on, and the replies of every local Bot that may speak there; the friend's Bot's lines come back into the room that the local Bots read. A local Bot that is not shared with that friend would either be silently cut off from the friend (its lines are filtered, `mayShareRoom`) or be exposed to the friend's Bot's text — so the host refuses instead of half-including it, and the Bot Social server would refuse its messages anyway (no grant on its side). The refusal is not a dead end: it names the Bot and the friend (`details.reason: 'ungranted-local'`, `bots`, `remotes`, Bot names from local records, friend names through `cleanPeerName`) and the new-chat card offers **Copy invite link for {Bot}** (the Bot's own invite; the grant exists only after the friend accepts it) or **Leave {Bot} out**. Nothing is granted by the click, and no room-scoped grant is made for a plain group. - Every posted line carries `authorId` + `authorKind` + a display snapshot (`who`). The signed-in actor wins; `body.authorId` grants nothing. Peer names are sanitized (`cleanPeerName` / `cleanRemote`). - **History-visibility rule (membership window):** only a *current* participant — or the owner of an invited Bot — may read a room. They see lines with `seq >=` the `joinedSeq` of their open interval (for an invited owner, their Bot's interval). Leaving closes the interval and withdraws all history access. Rejoining opens a new interval at the then-current seq; prior intervals do not come back. Non-members cannot read or inject. - Endpoints: `room-history`, `room-post`; `create-room` / `update-room` accept `participants` / `addParticipants` / `removeParticipants`. `view()` presents each viewer under the table below. Inbound projections (`origin: 'inbound-share'`, `rt.recordInboundSharedRoom` / `applyInboundRoomShare`) are the remote owner's copy of a friend's room that invited their granted Bot, created from a `FLYBOT-ROOM-SHARE` envelope when the Bot is added (not only after the first chat line). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 72ec22f5..d1d67c0f 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -465,7 +465,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + 'Invite link for {name} copied. Send it to your friend; once they accept, create the group again.': '{name} 的邀请链接已复制。发给好友,对方接受后再重新建群。', + 'Invite link': '邀请链接', + 'Could not copy the invite link for {name}. Copy the link below and send it to your friend; once they accept, create the group again.': '无法自动复制 {name} 的邀请链接。请手动复制下面的链接发给好友,对方接受后再重新建群。', -+ 'Could not create the invite. Use Bot settings → Invite to this Bot.': '无法创建邀请。请在 Bot 设置里使用 Invite to this Bot。', ++ 'Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot.': '无法创建邀请。请在 Bot 设置 → Sharing 里使用 Copy invite link for this Bot。', + 'Add friend': '添加好友', + 'Friend requests': '好友请求', + 'Accept or decline incoming requests.': '接受或拒绝收到的请求。', diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index d81af4b1..e03ebe10 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1564,7 +1564,7 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js + if (!link) throw new Error('no link') + } catch { + setInviteLink('') -+ setFixNote(t('Could not create the invite. Use Bot settings → Invite to this Bot.')) ++ setFixNote(t('Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot.')) + return + } + if (await copiedToClipboard(link)) { diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index c47b4df3..2f1d247a 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -64,7 +64,7 @@ diff --git a/client.js b/client.js + "Invite link for {name} copied. Send it to your friend; once they accept, create the group again.": "{name} 的邀请链接已复制。发给好友,对方接受后再重新建群。", + "Invite link": "邀请链接", + "Could not copy the invite link for {name}. Copy the link below and send it to your friend; once they accept, create the group again.": "无法自动复制 {name} 的邀请链接。请手动复制下面的链接发给好友,对方接受后再重新建群。", -+ "Could not create the invite. Use Bot settings → Invite to this Bot.": "无法创建邀请。请在 Bot 设置里使用 Invite to this Bot。", ++ "Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot.": "无法创建邀请。请在 Bot 设置 → Sharing 里使用 Copy invite link for this Bot。", + "Add friend": "添加好友", + "Friend requests": "好友请求", + "Accept or decline incoming requests.": "接受或拒绝收到的请求。", @@ -9692,7 +9692,7 @@ diff --git a/client.js b/client.js + if (!link) throw new Error("no link"); + } catch { + setInviteLink(""); -+ setFixNote(t("Could not create the invite. Use Bot settings → Invite to this Bot.")); ++ setFixNote(t("Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot.")); + return; + } + if (await copiedToClipboard(link)) { diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 00ada390..a27cffed 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -946,7 +946,7 @@ export function install(rt) { list: () => team().map(view), find: name => view(rt.findBot?.(name)), isRemote, - // Whether this Bot has at least one person-grant (Invite to this Bot was accepted). + // Whether this Bot has at least one person-grant (Sharing → Copy invite link was accepted). hasGrant: (id, peer, roomId) => rt.hasGrant?.(id, peer, roomId) === true, hasAnyGrant: id => rt.hasAnyGrant?.(id) === true, of: sessionId => view(rt.botOf(rt.selfOf(sessionId))), diff --git a/patches/ds-bot/files/src/host/reach.js b/patches/ds-bot/files/src/host/reach.js index 48acaa0d..041fe9b4 100644 --- a/patches/ds-bot/files/src/host/reach.js +++ b/patches/ds-bot/files/src/host/reach.js @@ -7,7 +7,7 @@ // // There is no global "published" flag. Every access is an independent per-Bot GRANT // (one of my Bots → one grantee; optionally scoped to a group). Default deny, Chief -// included. A grant is created when someone accepts "Invite to this Bot", or implicitly +// included. A grant is created when someone accepts a Sharing → Copy invite link, or implicitly // when the owner brings their own Bot into a face-to-face room by its 6-digit number // (`viaCode: true`, this room only, while a member). // diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 59d1a9ed..f3e585ef 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -485,7 +485,7 @@ export function install(rt) { const names = localBots.map(bot => bot.name).join(', ') const friends = [...new Set(remotes.map(p => p.owner || p.name))].join(', ') || 'a friend' const many = localBots.length > 1 - const message = `${names} ${many ? 'are' : 'is'} not shared with ${friends}, so ${many ? 'they' : 'it'} cannot be in a group with their Bot: the group's messages go to ${friends}'s home. Share ${many ? 'them' : 'it'} with ${friends} (Bot settings → Invite to this Bot), or leave ${many ? 'them' : 'it'} out.` + const message = `${names} ${many ? 'are' : 'is'} not shared with ${friends}, so ${many ? 'they' : 'it'} cannot be in a group with their Bot: the group's messages go to ${friends}'s home. Share ${many ? 'them' : 'it'} with ${friends} (Bot settings → Sharing → Copy invite link for ${names}), or leave ${many ? 'them' : 'it'} out.` return Object.assign(forbidden(message), { details: { reason: 'ungranted-local', bots: localBots, remotes } }) } diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index 257065e8..38d3df84 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -935,7 +935,7 @@ test('an ungranted local Bot refused next to a friend\'s Bot: the error names it await assert.rejects(async () => rt.applyParticipants({ id: 'new', members: [], participants: [], createdAt: 1 }, drafts, actorA), (error) => { assert.equal(error.code, 'bot/forbidden') assert.match(error.message, /^Writer A is not shared with Bob, so it cannot be in a group with their Bot/) - assert.match(error.message, /Invite to this Bot/) + assert.match(error.message, /Sharing → Copy invite link for Writer A/) assert.match(error.message, /leave it out/) assert.deepEqual(error.details, { reason: 'ungranted-local', diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index 499910cc..4860c67e 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -31,7 +31,7 @@ export interface BotView { name: string role?: string isMain: boolean - /** True when this Bot has at least one person-grant (Invite to this Bot was accepted). */ + /** True when this Bot has at least one person-grant (Sharing → Copy invite link was accepted). */ granted: boolean createdBy: BotId | null /** Text prompt.js appends to this Bot's system prompt (bot.setSystemPrompt). */ diff --git a/plugins/social/README.md b/plugins/social/README.md index 5899a61b..50230e5d 100644 --- a/plugins/social/README.md +++ b/plugins/social/README.md @@ -18,7 +18,7 @@ browser: `client.js`. Design: see "Social design: homes and members" in the root 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. + flag: access is a per-Bot **grant** (Sharing → Copy invite link → accept). Default deny, Chief included. Deleted Bots are revoked on `bot.removed`. Ungranted Bots are not registered at start and are not polled. **Face-to-face join** of my own Bot is an implicit room-scoped grant: if it has no identity yet, the plugin registers it room-only (`social.member.roomOnly`). That identity does diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index d04fbdaa..495423a9 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -7,7 +7,7 @@ // - Every local Bot is a member identity @., keys custodied by the server. // A Bot is registered when it is invited, when it joins an invite, or at face-to-face // join (room-only). There is no global "published" flag: access is a per-Bot GRANT -// (Invite to this Bot → accept). Default deny, Chief included. Revoked on 'bot.removed'. +// (Sharing → Copy invite link → accept). Default deny, Chief included. Revoked on 'bot.removed'. // A face-to-face room-only identity is revoked when the Bot leaves its last face room. // - Friendship is Bot-to-Bot: an invite names one local Bot and the friend joins as one of // theirs; each side sees and reaches only the Bots granted to them (the server's diff --git a/plugins/tree/README.md b/plugins/tree/README.md index 11c472db..f013b4eb 100644 --- a/plugins/tree/README.md +++ b/plugins/tree/README.md @@ -14,7 +14,7 @@ Recursive bot tree plugin for FlyBot (built on ds-bot / DeepSeek Harness). - **Deleting a bot:** its children re-attach to its parent and every moved child (and its subtree) gets a fresh prompt/trace. No child is dropped. If that parent is now over `maxChildren`, it is regrouped by role automatically (#446), the same way as a full level, until it is within the limit. Each move is journaled and logged. - **`create_child_bot` is serialized per parent:** parallel calls never exceed `maxChildren` or create duplicate-name siblings. - Delegation is level by level: coordinators delegate to direct children whose scope fits and aggregate upward; a bot with no children does the work itself. A one-line trace like `Chief → Work → Dev` is added only to answers a tree bot sends to its parent tree bot; replies to the user or to a remote (friend's, Bot Social) bot never carry it, and the root (Chief) never adds one. -- Tools (visible to every tree bot, not only Main/root — #16 Option B): `list_children()`, `find_bots(query?)`, `create_child_bot(name, role_description, parent?)` and `create_team(team, members, parent?)`. A bot creates only in its own downstream: `parent` (id or name) must be the caller or one of its descendants, and limits are checked at that parent. **Forward allocation (#447, `src/allocate.js`):** `find_bots` (read-only) lists the bots in the caller's branch (Chief: the whole tree) with role, parent, depth and path, matches first; `create_child_bot` reuses a bot with the same role anywhere in the caller's branch instead of creating a duplicate (`{ reused, note }`), and without `parent` puts the new bot under a marked group lead in the branch that its role belongs to ("FlyBot Docs" under "FlyBot Lead"), else under the caller; `create_team` makes a project team as a lead plus children (`Chief → FlyBot Lead → {Dev, Test, …}`, `members` as `"Name: role"` strings), one slot under the parent, reusing an existing lead and same-role members (a flat one moves under the lead, journaled and undoable). Allocation uses the self-organization's primitives (role profiles, leads, moves, journal); a full level, orphans (a bot whose parent is gone is reattached by role on the next repair pass) and duplicates are left to the self-organization, which never asks first. There is no plan card. Limits: depth 5 (root = 1), 8 children per node; duplicate sibling names (case/space-insensitive) are rejected; names are 1–40 characters (ds-bot's roster limit). Every new child gets the same recursive system prompt template (`src/prompt.js`). A new child starts with no person-grant even if its parent is granted; sharing it still needs an independent Invite to this Bot (default deny). +- Tools (visible to every tree bot, not only Main/root — #16 Option B): `list_children()`, `find_bots(query?)`, `create_child_bot(name, role_description, parent?)` and `create_team(team, members, parent?)`. A bot creates only in its own downstream: `parent` (id or name) must be the caller or one of its descendants, and limits are checked at that parent. **Forward allocation (#447, `src/allocate.js`):** `find_bots` (read-only) lists the bots in the caller's branch (Chief: the whole tree) with role, parent, depth and path, matches first; `create_child_bot` reuses a bot with the same role anywhere in the caller's branch instead of creating a duplicate (`{ reused, note }`), and without `parent` puts the new bot under a marked group lead in the branch that its role belongs to ("FlyBot Docs" under "FlyBot Lead"), else under the caller; `create_team` makes a project team as a lead plus children (`Chief → FlyBot Lead → {Dev, Test, …}`, `members` as `"Name: role"` strings), one slot under the parent, reusing an existing lead and same-role members (a flat one moves under the lead, journaled and undoable). Allocation uses the self-organization's primitives (role profiles, leads, moves, journal); a full level, orphans (a bot whose parent is gone is reattached by role on the next repair pass) and duplicates are left to the self-organization, which never asks first. There is no plan card. Limits: depth 5 (root = 1), 8 children per node; duplicate sibling names (case/space-insensitive) are rejected; names are 1–40 characters (ds-bot's roster limit). Every new child gets the same recursive system prompt template (`src/prompt.js`). A new child starts with no person-grant even if its parent is granted; sharing it still needs an independent Sharing → Copy invite link (default deny). - Frontend (`client.js`, bundle id `@flybot/tree`, injects `flybot`): the tree is drawn by ds-bot's own Bot list, so this plugin's separate "Tree" sidebar section is **off by default** (`sidebarSection: true` in the client config brings it back). It always exposes the synchronous helper `ctx.flybot.tree.forest(bots)` (also `globalThis.__flybot__.tree.forest(bots)`), see **Tree data** below. It also registers `flybot.sidebar.structure` (`{ parentOf(botId) }`, parents from `forestOf` over `ctx.flybot.bots()`, refreshed on field changes and every 10 s); ds-bot's sidebar uses it to draw its Bot list as one expandable tree with Chief on top. Retired, archived and outside Bots and other Main Bots have no parent there, so ds-bot shows them directly under Chief; the entry's `nodeActions` draws a tag after such a row ("archived", "archived · merged into …", "outside the tree") and after an idle Bot ("idle"), so the sidebar and the tree model agree. A `flybot.sidebar.section` entry `flybot-tree-reorg` shows the last reorganization (what moved) with Undo and Dismiss, through the host endpoints `tree/reorg`, `tree/undo`, `tree/dismiss`; it draws nothing when there is none in the last 7 days (`reorgNotice: false` in the client config hides it). The entry also has `menuItems({ bot })`, which ds-bot adds to each Bot's right-click menu: one item, `{ id: 'copy-invite', label: 'Copy invite link' }` (with `visible`, `run`); the invite with instructions is offered in the Bot's Sharing section only, so the menu does not list the same invite twice (#173). `visible` is `social.canInvite(bot.id) === true` (false without the social plugin); `run` calls `social.createInvite(botId)` (or `flybot.call('social.createInvite', { botId })`). **Copy invite link** copies only the bare URL (`link`); `copyInvite(flybot, botId, 'text')` copies the full message `text` for other callers; a missing field is an error, never swapped for the other. The clipboard write starts inside the click via `ClipboardItem` with the text as a promise, so it keeps the click's permission while the invite is created; `writeText` then a textarea are the fallbacks. It returns "Invite link for copied", which ds-bot shows as a toast; a failure throws and shows as the error toast. Right-clicking never opens the Bot's chat. ### Config diff --git a/plugins/tree/src/adapter.js b/plugins/tree/src/adapter.js index 66164b14..c0b46560 100644 --- a/plugins/tree/src/adapter.js +++ b/plugins/tree/src/adapter.js @@ -10,7 +10,7 @@ // bot.send.use({ name, priority?, before?, after?, handle(msg) }) -> dispose (bands: bot.send.priority) // msg.target: { remote: false, id } | { remote: true, handle, home } // bot.isRemote(ref) -> boolean ({ kind: 'remote' } or '@handle'; local ids never) -// bot.hasAnyGrant(id) -> boolean (a person accepted Invite to this Bot; may be absent on an older core) +// bot.hasAnyGrant(id) -> boolean (a person accepted Sharing → Copy invite link; may be absent on an older core) // on('turn.start' | 'tool.start', fn) -> dispose (team activity feed: ids, times, tool names; no text) // api.handle(name, fn, { read? }) -> dispose (UI endpoint, called from the browser as flybot.call(name)) // tools.register({ name, description, parameters, visibleTo(botId) SYNC, execute(args, { botId }) }) diff --git a/plugins/tree/src/middleware.js b/plugins/tree/src/middleware.js index 98a6bb8f..bb13d997 100644 --- a/plugins/tree/src/middleware.js +++ b/plugins/tree/src/middleware.js @@ -7,7 +7,7 @@ // - A non-root tree Bot sending to a LOCAL Bot outside the tree is denied; the root (Chief, the // user's main bot) may message any local bot. // - A local non-tree sender may reach only the tree root; other tree nodes are denied. -// - Granted Bots on both sides (someone accepted Invite to this Bot) are not the tree's call +// - Granted Bots on both sides (someone accepted Sharing → Copy invite link) are not the tree's call // across trees or to non-tree Bots: undefined, and the core's owner policy decides who may // reach whom. Inside one tree the parent/child rule still holds. // - Non-tree -> non-tree traffic is not the tree's call: undefined.