diff --git a/changelog.d/995-access-one-row-per-bot.fixed.md b/changelog.d/995-access-one-row-per-bot.fixed.md new file mode 100644 index 000000000..05c6e13c7 --- /dev/null +++ b/changelog.d/995-access-one-row-per-bot.fixed.md @@ -0,0 +1 @@ +**People with access** now lists one row per Bot instead of one per person, named `Bot (Owner)` and carrying that Bot's handle, so a friend who runs two Bots shows two rows and each **Revoke** takes back only the row it sits on — before this, one Revoke removed every grant that friend had ([#995](https://github.com/ThinkFlowLab/FlyBot/issues/995)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 846d1738f..dba7f3755 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). **Join or create by number or link** (zh 面对面建群或用链接加入, #892; it covers both ways in: a six-digit number entered together, or a group invite link pasted or scanned) and **Add friend** are those rows (copying an invite and your own handle live inside Add friend); the social strip has no **+ Add** button. That entry opens the same screen, titled the same, whose copy names both ways and whose link field reads *Or paste a group invite link* (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 — one row per **Bot**, not per person, labelled `Bot (Owner)` and carrying that Bot's handle and `peer_bot_id`, so a friend who runs several Bots gets a row each and the click grants to the row's own Bot while the receipt names it (#973) — from the row label the click carries, not from the home key, so a server answer without names never prints `selftestb2` (#994), and a caller that sends no label gets `with your friend` instead; the filter asks per **Bot** too, so a friend whose Bot already has it still gets a row for the others and only a grant that names no Bot falls back to hiding that whole friend (#981); the friend is named by `friend_bot_id`, the peer id `list_friends` returns — the platform never lists a friend's owner id — and `friend_owner_id` still works for a server that only knows it (#592); a server without that tool tries the REST route `POST /v1/bots/:id/grants` and, when it 404s too, says *Bot Social needs an update to share with a friend. Invite links still work.* instead of the raw MCP error (the production `MCP error -32602: Tool … not found` wording counts as an unknown tool, #563); a server that has the tool but refuses the arguments says *Bot Social did not accept that share request. Its server may need an update; invite links still work.*, never the raw `-32602` text (#592); A failed **Share** and a failed friend-list read keep separate notes (#987) — two slots, not one (#992): the share outcome (`share-note`) and the list error (`share-list-error`) are drawn side by side, so a share click cannot erase the list error and its **Try again**, and the 5 s poll cannot erase the share's message; a successful read settles the list error only, a successful share settles the share line only, and "No friends left to share with" is suppressed by a list error only — a share error never speaks for the list. **People with access** lists who has that Bot, in which groups, and **Revoke**. The hint stays one readable row however long the share lives (#955): it names at most three rooms and then `+N more`, keeps every room one per line in its tooltip, and a room a grant row already named is not printed again under **This room only**. There is no published toggle. A line that Bot sends 1:1 to a seat in that room is judged under the room (#948): the plugin resolves it before the member check (`roomFor` → `roomContextFor`), the seat itself is the target when the roster does not list it (`pairFor`), and the send names that room (`room_id`) so Bot Social checks the same consent the host already did; the `roomOnly` identity is neither dropped nor widened — with no such room the refusal is the same sentence as before, and `send_as_member` is never reached. 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). **Join or create by number or link** (zh 面对面建群或用链接加入, #892; it covers both ways in: a six-digit number entered together, or a group invite link pasted or scanned) and **Add friend** are those rows (copying an invite and your own handle live inside Add friend); the social strip has no **+ Add** button. That entry opens the same screen, titled the same, whose copy names both ways and whose link field reads *Or paste a group invite link* (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 — one row per **Bot**, not per person, labelled `Bot (Owner)` and carrying that Bot's handle and `peer_bot_id`, so a friend who runs several Bots gets a row each and the click grants to the row's own Bot while the receipt names it (#973) — from the row label the click carries, not from the home key, so a server answer without names never prints `selftestb2` (#994), and a caller that sends no label gets `with your friend` instead; the filter asks per **Bot** too, so a friend whose Bot already has it still gets a row for the others and only a grant that names no Bot falls back to hiding that whole friend (#981); the friend is named by `friend_bot_id`, the peer id `list_friends` returns — the platform never lists a friend's owner id — and `friend_owner_id` still works for a server that only knows it (#592); a server without that tool tries the REST route `POST /v1/bots/:id/grants` and, when it 404s too, says *Bot Social needs an update to share with a friend. Invite links still work.* instead of the raw MCP error (the production `MCP error -32602: Tool … not found` wording counts as an unknown tool, #563); a server that has the tool but refuses the arguments says *Bot Social did not accept that share request. Its server may need an update; invite links still work.*, never the raw `-32602` text (#592); A failed **Share** and a failed friend-list read keep separate notes (#987) — two slots, not one (#992): the share outcome (`share-note`) and the list error (`share-list-error`) are drawn side by side, so a share click cannot erase the list error and its **Try again**, and the 5 s poll cannot erase the share's message; a successful read settles the list error only, a successful share settles the share line only, and "No friends left to share with" is suppressed by a list error only — a share error never speaks for the list. **People with access** lists one row per **Bot** that has this Bot — named `Bot (Owner)` like the share list, carrying that Bot's handle, the groups it is used in, and its own **Revoke** (#995) — plus one **Auto-send** switch per person. The hint stays one readable row however long the share lives (#955): it names at most three rooms and then `+N more`, keeps every room one per line in its tooltip, and a room a grant row already named is not printed again under **This room only**. There is no published toggle. A line that Bot sends 1:1 to a seat in that room is judged under the room (#948): the plugin resolves it before the member check (`roomFor` → `roomContextFor`), the seat itself is the target when the roster does not list it (`pairFor`), and the send names that room (`room_id`) so Bot Social checks the same consent the host already did; the `roomOnly` identity is neither dropped nor widened — with no such room the refusal is the same sentence as before, and `send_as_member` is never reached. 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 @@ -127,7 +127,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - **#136:** A taken `displayName` at onboard retries `Name2` … rather than refusing to start. Your handle is shown in Add friend (#181). - **#137:** Owner-chosen Bot avatar/color is sent with MCP `update_bot_profile { bot_id?, display_name?, avatar?, color? }` (REST `PATCH /v1/bots/{id}/profile`) whenever a local Bot's look changes, on startup if not yet synced, and again for every new identity of that Bot (the synced record names the identity: a room-only identity is released after its last face room and the next room registers a new one, #182). `avatar` is a preset id, a single emoji, or `data:image/(png|jpeg|webp);base64` ≤ 64KiB; `color` is `#rrggbb`. Remote look is read from `list_remote_bots` / `list_friends` peer / `list_bot_grants` / face-room `members[]` (`avatar`, `avatar_kind`, `color`, `avatar_updated_at`) and shown in the main sidebar list, group member lists, waiting tiles, the picker, and chat headers. Cache key is `avatar_updated_at`; missing look falls back to the handle-seeded palette. What is sent is the look the owner's home draws (`shownLook` in `look.js`): a Bot with no stored shape or color gets the id-picked default of core `characters.js` (`defaultShapeOf` / `defaultColorOf`, kept equal to core by a test), so the other home shows that look and not a handle-seeded one; a stored image or emoji is sent as is, image before emoji before shape as `BotMark` draws them. A new identity gets its look before `registerOnce` returns, so a face-room join never shows peers a seeded look first (#182). In the waiting dialog my own Bot is found by its room record (`remote: false`, local id), the Bot list handle, or the `joinedAs` of this join (`mineBots`), and always uses the roster look (#182). Face-room events carry no look — `members[]` is the source. All fields are optional so an older Bot Social is unchanged. -- **#138 / #161 / #164:** 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)`; a label `grantPersonView` already made keeps its display name when `syncOutgoingGrants` reads the normalized grant again (#275), and only a bare handle falls back to the lowercase home. `grantPersonView` / `normalizeGrant` read Bot Social's object-shaped `grantee` (`handle`, `owner_display_name`) and never `String()` it (`[object Object]` → `@objectobject`). Core grants store a canonical `@handle` (never decorated `Chief@…`). Friends' Bots drops reverse-friends of invites this home created (`outgoingPeerHandles`) and room-only remotes, but a handle the joiner explicitly received (`inboundBots[handle]`) always shows (#178): the talk-through grant on the joiner's own Bot names the shared Bot as grantee and must not hide it, nor appear in that Bot's People with access (`isTalkThroughGrant`). Bot Social's `list_bot_grants` marks that grant `reply_only` (bot-social #23), which `normalizeGrant` keeps as `replyOnly` (with `side`); when present it is the only rule (#495): `replyOnly: true` is talk-through and `replyOnly: false` is always a share, so a share in a relationship both homes recorded as joined (two friends joining each other's invite, which Bot Social regrants into the first relationship) stays listed and revocable on both homes, and a confirmed share is never kept apart as a possible talk-through grant in `accessRows`. Only a server that does not send the field falls back to the guess: grantee in `inboundBots`, and either a relationship this home joined or, for joins from before that record, the home's first relationship with the Bot in `via`. `social/join-invite` records the joined relationship (`settings.joinedRelationships[rid]`), because Bot Social lists a friendship's relationships (`relationship_id` = the first, `relationship_ids` = all) without saying who joined, and both sides of a relationship hold a grant naming the other's Bot: matching any of `relationship_ids` would hide a real share when a Bot shared with us later joins our invite (#383: an already-shared Chief that joined the friend's Helper invite listed the friend twice; a join made before the record, in a second relationship, still shows twice until it is joined again). `social/access` returns **one row per person** (`accessRows`, keyed by the person's home): a row carries `grantIds`, and `social/revoke-grant` with `grantIds` revokes each of them, so two shares of one Bot to one person are one row and one Revoke. A grant to a Bot shared with us in a relationship not recorded as joined (possibly the talk-through side of an older join) keeps a row of its own and is never in another row's `grantIds`, so the person's Revoke cannot break that chat. Every revoke runs in a per-Bot fence: a `syncOutgoingGrants` whose `list_bot_grants` overlapped it does not store that list and resolves `false` (fail closed), and the roster refreshes once after the fence. The joined relationship and the offered Bot are recorded right after `join_invite`, before the owner-approval follow-up. Join copy names the person (`owner_display_name`), not the shared Bot. `social/join-invite` marks the offered Bot inbound **before** its first roster refresh (#228): the grant sync that refresh starts then already sees the talk-through grant as talk-through, so no core grant on the joiner's Bot names the inviter's Bot and the re-filter does not drop the shared Bot until a later refresh (it shows with the success toast). Bot Social's real `join_invite` answer has no `bot` field: `inviter` is the invited Bot itself (a member handle such as `@chief.bob.home`; `joined.bot`, `inviter.bot` and `offered_bot` are the older shapes, with `inviter` at the home). A member-level `inviter` counts as the offered Bot, so it is marked inbound before the refresh and the toast names it (#342; without it #303's awaited sync stored the server's talk-through grant on the joiner's Bot as the joiner sharing back, and the re-filter hid the inviter's Bot and listed the inviter in the joiner's People with access). A talk-through grant is never sharing: the joiner's Bots stay default-deny after a join. A Bot that appears on a roster this home already fetched and is not in `inboundBots` waits for the grant sync before the roster is published (#303; "already fetched" is the `rosterFetched` flag, so an empty roster of a home with no friends yet counts and the first friend's Bot waits too; the very first fetch does not wait, #92, as `registerAll` synced grants at start): the Bot that just joined our invite would otherwise be published for a moment, get a chat record on this home and be marked gone seconds later (the next load opened that dead chat). The awaited sync classifies talk-through grants against the fresh roster (`syncOutgoingGrants({ roster })`), not the previous `remote`, so a refresh that also brings another new Bot cannot store a talk-through grant as a core grant. `syncOutgoingGrants` resolves `false` when any Bot's grants could not be listed; the publish then fails closed: the unseen Bots are withheld from this publish and from the background re-filter (`withheldRemotes`), stay unseen for the next fetch (which syncs again), and appear only after a sync succeeds. Bots already seen and Bots shared with us never wait, so the usual refresh still re-filters in the background. A late grant re-filter of an older roster fetch never overwrites a newer one (fetch counter, not a timestamp). Fetches are numbered when they ask, not when they land (#224): a `list_remote_bots` answer that lands after a newer one (the background refresh `hide-remote` starts, a slow link) is dropped, so it cannot hide a re-shared Bot again; the grant re-filter applies the same answer again and does not count as a second absent fetch toward forgetting a local hide. An open **People with access** re-reads `social/access` every 5 s while visible, and `social/access` syncs only that Bot's grants (`syncOutgoingGrants({ only })`). Bot Social itself still records the talk-through side of the relationship as a grant on the joiner's Bot (`list_bot_grants`); FlyBot filters it on both sides (#161, #178). On upgrade, remotes already on `list_remote_bots` are seeded into `inboundBots` once so reciprocal friends are not dropped — except the Bot that joined our invite and face-room-only remotes. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only. +- **#138 / #161 / #164:** 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)`; a label `grantPersonView` already made keeps its display name when `syncOutgoingGrants` reads the normalized grant again (#275), and only a bare handle falls back to the lowercase home. `grantPersonView` / `normalizeGrant` read Bot Social's object-shaped `grantee` (`handle`, `owner_display_name`) and never `String()` it (`[object Object]` → `@objectobject`). Core grants store a canonical `@handle` (never decorated `Chief@…`). Friends' Bots drops reverse-friends of invites this home created (`outgoingPeerHandles`) and room-only remotes, but a handle the joiner explicitly received (`inboundBots[handle]`) always shows (#178): the talk-through grant on the joiner's own Bot names the shared Bot as grantee and must not hide it, nor appear in that Bot's People with access (`isTalkThroughGrant`). Bot Social's `list_bot_grants` marks that grant `reply_only` (bot-social #23), which `normalizeGrant` keeps as `replyOnly` (with `side`); when present it is the only rule (#495): `replyOnly: true` is talk-through and `replyOnly: false` is always a share, so a share in a relationship both homes recorded as joined (two friends joining each other's invite, which Bot Social regrants into the first relationship) stays listed and revocable on both homes, and a confirmed share is never kept apart as a possible talk-through grant in `accessRows`. Only a server that does not send the field falls back to the guess: grantee in `inboundBots`, and either a relationship this home joined or, for joins from before that record, the home's first relationship with the Bot in `via`. `social/join-invite` records the joined relationship (`settings.joinedRelationships[rid]`), because Bot Social lists a friendship's relationships (`relationship_id` = the first, `relationship_ids` = all) without saying who joined, and both sides of a relationship hold a grant naming the other's Bot: matching any of `relationship_ids` would hide a real share when a Bot shared with us later joins our invite (#383: an already-shared Chief that joined the friend's Helper invite listed the friend twice; a join made before the record, in a second relationship, still shows twice until it is joined again). `social/access` returns **one row per Bot** (#995): `accessRows` keys a grant that names a Bot (its `member` differs from the person's home) by that Bot, labels it from the name Bot Social sent for it in `list_bot_grants` (`normalizeGrant` keeps `botName` / `ownerName`; a server that sends no name falls back to `Owner (@bot.home)`, never two identical person rows), and the row's Revoke takes exactly that grant — so one friend running two Bots is two rows and taking one back leaves the other. A grant that names no Bot keeps the per-person row it had, and a row still carries `grantIds`, which `social/revoke-grant` revokes one after another: grants of one Bot to one person (two invites) are that Bot's row, and #387's own-row talk-through grant is never in another row's `grantIds`. A grant to a Bot shared with us in a relationship not recorded as joined (possibly the talk-through side of an older join) keeps a row of its own and is never in another row's `grantIds`, so the person's Revoke cannot break that chat. Every revoke runs in a per-Bot fence: a `syncOutgoingGrants` whose `list_bot_grants` overlapped it does not store that list and resolves `false` (fail closed), and the roster refreshes once after the fence. The joined relationship and the offered Bot are recorded right after `join_invite`, before the owner-approval follow-up. Join copy names the person (`owner_display_name`), not the shared Bot. `social/join-invite` marks the offered Bot inbound **before** its first roster refresh (#228): the grant sync that refresh starts then already sees the talk-through grant as talk-through, so no core grant on the joiner's Bot names the inviter's Bot and the re-filter does not drop the shared Bot until a later refresh (it shows with the success toast). Bot Social's real `join_invite` answer has no `bot` field: `inviter` is the invited Bot itself (a member handle such as `@chief.bob.home`; `joined.bot`, `inviter.bot` and `offered_bot` are the older shapes, with `inviter` at the home). A member-level `inviter` counts as the offered Bot, so it is marked inbound before the refresh and the toast names it (#342; without it #303's awaited sync stored the server's talk-through grant on the joiner's Bot as the joiner sharing back, and the re-filter hid the inviter's Bot and listed the inviter in the joiner's People with access). A talk-through grant is never sharing: the joiner's Bots stay default-deny after a join. A Bot that appears on a roster this home already fetched and is not in `inboundBots` waits for the grant sync before the roster is published (#303; "already fetched" is the `rosterFetched` flag, so an empty roster of a home with no friends yet counts and the first friend's Bot waits too; the very first fetch does not wait, #92, as `registerAll` synced grants at start): the Bot that just joined our invite would otherwise be published for a moment, get a chat record on this home and be marked gone seconds later (the next load opened that dead chat). The awaited sync classifies talk-through grants against the fresh roster (`syncOutgoingGrants({ roster })`), not the previous `remote`, so a refresh that also brings another new Bot cannot store a talk-through grant as a core grant. `syncOutgoingGrants` resolves `false` when any Bot's grants could not be listed; the publish then fails closed: the unseen Bots are withheld from this publish and from the background re-filter (`withheldRemotes`), stay unseen for the next fetch (which syncs again), and appear only after a sync succeeds. Bots already seen and Bots shared with us never wait, so the usual refresh still re-filters in the background. A late grant re-filter of an older roster fetch never overwrites a newer one (fetch counter, not a timestamp). Fetches are numbered when they ask, not when they land (#224): a `list_remote_bots` answer that lands after a newer one (the background refresh `hide-remote` starts, a slow link) is dropped, so it cannot hide a re-shared Bot again; the grant re-filter applies the same answer again and does not count as a second absent fetch toward forgetting a local hide. An open **People with access** re-reads `social/access` every 5 s while visible, and `social/access` syncs only that Bot's grants (`syncOutgoingGrants({ only })`). Bot Social itself still records the talk-through side of the relationship as a grant on the joiner's Bot (`list_bot_grants`); FlyBot filters it on both sides (#161, #178). On upgrade, remotes already on `list_remote_bots` are seeded into `inboundBots` once so reciprocal friends are not dropped — except the Bot that joined our invite and face-room-only remotes. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only. - **#139:** **Remove this Bot** asks once, inline in the friend Bot's settings (`Remove Helper (Bob) from your list? Your own Bots stay shared. Bob can share it again.` · **Remove** · **Keep**, #230, #496; never the browser `confirm()`), and the friend's **Auto-send** checkbox says what it covers (`Auto-send my Bots' replies to Bob` with a one-line hint, #417) and calls `decline_bot_grant { grant_id, bot_id }` (`DELETE /v1/remote-bots/{grantId}`) so the owner's Manage access drops it. `grant_not_found` is treated as already removed. An older server (404 on the tool/endpoint) falls back to the local hide. Re-joining after a decline may return `regranted: true` on the existing friendship; live access still answers `409 relationship_exists`; a spent invite is `410 invite_unavailable`. Copy stays English (“You already have Helper (Bob).”, “This invite was already used.”). Re-joining an existing friendship unhides only that invite's Bot/peer home. - **#496:** re-accepting an invite for a Bot this home already has is a result, not an error. On `409 relationship_exists` `social/join-invite` answers `{ alreadyHave: true, status: 'active', bot, botName, ownerName, restored: [{ handle, name, owner }] }`. Bot Social's 409 names no Bot, so the plugin reads the public invite info (`GET /v1/invites/{code}`, `inviteInfo` in `mcp-client.js`, sent without a token) for the invite's Bot and owner. `restored` lists only Bots that were locally hidden or declined and are listed again after the unhide. The panel shows neutral text `You already have Chief (SelfA1233).` (Bot first, owner in parentheses, #384), or `Chief (SelfA1233) is back in your list.` when something really came back; never red. Without a home (legacy) the join still throws, with the neutral `You already have this friend's Bot.`. - **#140:** Auto-send defaults off. Accepting a friend request uses that relationship's peer home for Auto-send, not the first roster entry. The owner sees Approve / Reject in the chat, not “Waiting for the owner's approval.” Composer drafts are labelled You. @@ -156,7 +156,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - One term, **Auto-send**, on the card, the toast and the setting. The held-message card's quiet link reads **Auto-send from now on** (it was Always approve): it approves this card and turns on Auto-send for that pair only, my Bot that wrote it → that person. The toast names both and the place to undo it: `Auto-send is on for Chief → SelfB1238. Turn it off in Chief → Sharing → People with access.` A face-room line keeps face Auto-send (`Turn it off in the room overview.`). - Stored per pair like per-Bot grants: the local Bot's field `autoApprove[home]` (`social/auto-send { botId, home, on }` from the switch, or `{ approvalId }` from the card link, where the host reads the sender Bot and the recipient's home from the hold itself; `approvalFor` reads it first). An older home-wide entry (`settings.autoAccept[home]`) still reads as on for every Bot of mine to that person until one is changed. Bot Social's `set_auto_approve { relationship_id, auto_approve_chat }` is set on that Bot's own friendships with the person; a friendship held by the home identity is turned off only when no other Bot of mine still auto-sends there. The ids set this way go into `serverAutoApproved`, so the home-wide catch-up sync leaves them as chosen. The owner's switch or link is the consent; the confirm code is used by the plugin in the same call and never asked of anyone. -- Visible and reversible where the approver is: each person row in a local Bot's **Sharing** → **People with access** (`social/access` rows carry `person` and `autoSend`) has an **Auto-send** checkbox (default off) next to **Revoke**; off brings back the card on the next reply. +- Visible and reversible where the approver is: each person's rows in a local Bot's **Sharing** → **People with access** (`social/access` rows carry `person` and `autoSend`) get one **Auto-send** checkbox (default off, on the first of that person's rows since it is one pair setting, #995) next to **Revoke**; off brings back the card on the next reply. - The friend's Bot settings checkbox is for all my Bots: **Auto-send my Bots' replies to Bob** (`social/auto-accept`, home-wide) with the hint `Your Bots' replies to Bob go out without asking you first. What you type is not affected.` Setting it clears each Bot's own choice for that person; it reads on only while every Bot of mine auto-sends to them (`social/settings`). - A Bot Social hold no Bot of mine wrote turns nothing on (`Sent. Auto-send was not turned on: no Bot of yours wrote this.`). Where this section and the card paragraphs above differ (Always approve, `social/auto-accept` per friend, `Auto-send on for X → Y`), this section is current. diff --git a/plugins/social/client.js b/plugins/social/client.js index 545840cd5..03a4fd46c 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -2945,7 +2945,7 @@ window.__ModuleLoader__.load({ } const ACCESS_POLL_MS = 5000 - const ACCESS_NOTE = 'One row per person. Revoke anyone without affecting the rest. Default: nobody, Chief included.' + const ACCESS_NOTE = 'One row per Bot. Revoke one without affecting the rest. Default: nobody, Chief included.' function ManageAccess({ bot, onGrants }) { const [view, setView] = useState(null) const [busy, setBusy] = useState('') @@ -2994,6 +2994,8 @@ window.__ModuleLoader__.load({ finally { setBusy('') } } const grants = view?.grants ?? [] + // One Auto-send switch per person, however many of their Bots are rows (#995). + const autoShown = new Set() // A room the grant rows already named must not come back under "This room only" (#955). const listedRoomIds = grants.flatMap(grant => roomList(grant.rooms).map(row => row.id)) const roomOnlyUsage = roomHint('only', (view?.rooms ?? []).filter(room => room.origin === 'face-to-face'), listedRoomIds) @@ -3003,14 +3005,21 @@ window.__ModuleLoader__.load({ grants.length === 0 ? h('div', { className: 'fs-hint' }, 'Nobody has this Bot yet. Copy an invite link above and send it to a friend.') : null, grants.map((grant) => { const grantUsage = roomHint('use', grant.rooms) + // Auto-send is one setting for the pair (this Bot → that person), so a friend whose two Bots + // are two rows still gets one switch (#995), on the first of their rows. + const autoKey = grant.person || grant.home + const showAuto = Boolean(grant.home) && !autoShown.has(autoKey) + if (showAuto) autoShown.add(autoKey) return 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), - grant.home && grant.name && !String(grant.name).includes(grant.home) - ? h('span', { className: 'fs-sub' }, grant.home) - : null, + grant.handle && grant.handle !== grant.home + ? h('span', { className: 'fs-sub' }, grant.handle) + : grant.home && grant.name && !String(grant.name).includes(grant.home) + ? h('span', { className: 'fs-sub' }, grant.home) + : null, grantUsage ? h('span', { className: 'fs-hint', title: grantUsage.title }, grantUsage.text) : null), - grant.home ? h('label', { className: 'fs-auto fs-access-auto', 'data-flybot': 'access-auto-send', title: `${bot.name || 'This Bot'}'s replies to ${grant.person || grant.home} go out without asking you first.` }, + showAuto ? h('label', { className: 'fs-auto fs-access-auto', 'data-flybot': 'access-auto-send', title: `${bot.name || 'This Bot'}'s replies to ${grant.person || grant.home} go out without asking you first.` }, h('input', { type: 'checkbox', checked: grant.autoSend === true, disabled: Boolean(busy), 'aria-label': `Auto-send ${bot.name || 'this Bot'} → ${grant.person || grant.home}`, diff --git a/plugins/social/src/mcp-client.js b/plugins/social/src/mcp-client.js index 36021d0bd..bbdaa9f0f 100644 --- a/plugins/social/src/mcp-client.js +++ b/plugins/social/src/mcp-client.js @@ -257,6 +257,18 @@ export function grantPersonView(grant) { return { home, member, person, label } } +// #995: a per-Bot grant names the Bot, so People with access can name it too. Bot Social sends the +// grantee as that Bot (handle + display_name + owner_display_name); a server that decorates the handle +// instead ("Chief@chief.bob.home") still carries the Bot name in front of it. +function grantBotNames(item, grantee) { + const source = [grantee, item?.bot, item?.peer].find(value => value && typeof value === 'object') + const pick = value => (typeof value === 'string' ? value.trim() : '') + const botName = pick(source?.display_name) || pick(source?.displayName) + || (typeof grantee === 'string' ? (grantee.match(/^(.+?)\s*@[^@\s]+$/) ?? [])[1]?.trim() : '') + const ownerName = pick(source?.owner_display_name) || pick(source?.ownerName) + return { ...(botName ? { botName } : {}), ...(ownerName ? { ownerName } : {}) } +} + export function normalizeGrant(item) { if (!item || typeof item !== 'object') return undefined const grantee = item.grantee ?? item.peer ?? item.handle ?? item.home @@ -269,6 +281,9 @@ export function normalizeGrant(item) { const view = grantPersonView({ grantee, name }) const canonical = view.member || view.home || memberHandleOf(grantee) if (!canonical) return undefined + // Only a grant that names a Bot (the member, not just the person's home) has a Bot name to show. + const perBot = Boolean(view.member && view.member !== view.home) + const names = perBot ? grantBotNames(item, grantee) : {} return { ...(id ? { id: String(id) } : {}), // Canonical @handle for core grants.set / normGrantee (not decorated Chief@…). @@ -276,6 +291,7 @@ export function normalizeGrant(item) { ...(view.label ? { name: view.label } : {}), ...(view.home ? { home: view.home } : {}), ...(view.member ? { member: view.member } : {}), + ...names, ...(item.relationship_id || item.relationshipId ? { relationshipId: String(item.relationship_id ?? item.relationshipId) } : {}), ...(item.room_id || item.roomId ? { roomId: String(item.room_id ?? item.roomId) } : {}), // Bot Social says which grants only let the grantee answer (the join's talk-through grant, bot-social diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 41af5b1a9..383e97eea 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -1364,6 +1364,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } // Per Bot, the grant ids Bot Social confirmed as full shares (`reply_only: false`) at the last sync (#495). const confirmedShares = new Map() + // Per Bot handle, the name Bot Social gave in the grant list. The host's grants store keeps only + // id/grantee/name/relationshipId/roomId, so a per-Bot row cannot read the Bot's own name back from it + // (#995); the sync that just listed the grants fills this instead. + const botNames = new Map() // `only`: one Bot's grants (an open People with access refreshes just that Bot). `roster`: the // friends' homes to classify talk-through grants against, when they are newer than `remote`. // Resolves true only when every Bot's grants were listed and stored; a failure is warned, not thrown. @@ -1385,6 +1389,14 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // the sync as not fresh so a roster refresh withholds unseen Bots (fail closed); the revoke's // own refresh lists again once it is done (Codex P1/P2 on #387). if (revoking.get(bot.id) || grantEpoch(bot.id) !== epoch) { ok = false; return } + for (const grant of listed) { + const view = grantPersonView(grant) + const handle = norm(view.member) + if (!handle || handle === norm(view.home)) continue + const botName = typeof grant.botName === 'string' ? grant.botName : '' + const ownerName = typeof grant.ownerName === 'string' ? grant.ownerName : '' + if (botName || ownerName) botNames.set(handle, { ...(botName ? { bot: botName } : {}), ...(ownerName ? { owner: ownerName } : {}) }) + } const roomScoped = (flybot.grants.of?.(bot.id) ?? []).filter(grant => grant.roomId) const people = listed.filter(grant => !grant.roomId && !isTalkThroughGrant(member, grant, roster ?? remote.homes)).map(grant => { const view = grantPersonView(grant) @@ -2501,21 +2513,26 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const entry = pausedChats.get(norm(handle)) return { paused: entry ? { reason: entry.reason, at: entry.at } : null } }, { read: true })) - // People with access is one row per person (#383): two grants of one Bot to the same person - // (two invites, joined by two of their Bots) are one row, and its Revoke removes both. + // People with access is one row per grant (#383), and one row per Bot since sharing is per Bot + // (#995): a friend who runs two Bots used to be a single row, so one Revoke took both grants. A + // grant that names a Bot (the member, not the person's home) gets its own row, labelled the way the + // share list labels it (`Bot (Owner)`, #973), and its Revoke takes that grant alone. A grant that + // names only the person — and #387's unsure talk-through grant — keep the row they had. function accessRows(botId, grants) { const rows = new Map() for (const grant of grants) { const view = grantPersonView(grant) const home = view.home || grant.home || (typeof grant.grantee === 'string' ? personHomeOf(grant.grantee) : '') const grantee = home || view.member || (typeof grant.grantee === 'string' ? grant.grantee : '') + const member = norm(view.member) + const perBot = Boolean(member && member !== norm(home)) // A grant to a Bot shared with us, in a relationship not recorded as joined here, may be a // talk-through grant from a join made before that record (#383): it keeps a row of its own, so // Revoke on the person's row never takes it along and breaks that chat (Codex P1 on #387). // A grant Bot Social confirmed as a full share is never unsure (#495). const toInbound = Boolean(settings.inboundBots?.[memberHandleOf(grant.grantee)]) && !confirmedShares.get(botId)?.has(grant.id) const unsure = toInbound && !(grant.relationshipId && settings.joinedRelationships?.[grant.relationshipId]) - const key = unsure ? `grant:${grant.id}` : (norm(grantee) || String(grant.id)) + const key = unsure ? `grant:${grant.id}` : perBot ? `bot:${member}` : (norm(grantee) || String(grant.id)) const rooms = roomsUsing(botId, view.member || grant.grantee) const row = rows.get(key) if (row) { @@ -2523,12 +2540,21 @@ export function createSocial(service, config = {}, { client: given, api: givenAp for (const room of rooms) if (!row.rooms.some(seen => seen.id === room.id)) row.rooms.push(room) continue } + // The Bot's own name is known only from the grant list the sync just read (#995); without it the + // row still names the Bot by the handle the grant carries, never two identical person rows. + const named = perBot ? botNames.get(member) : undefined + const owner = named?.owner || view.person + const botLabel = named?.bot ? (owner && owner !== named.bot ? `${named.bot} (${owner})` : named.bot) : '' + const personLabel = view.label || (typeof grant.name === 'string' ? grant.name : '') rows.set(key, { id: grant.id, grantIds: [grant.id], grantee, - name: view.label || (typeof grant.name === 'string' ? grant.name : ''), + name: perBot + ? botLabel || `${owner || member.replace(/^@/, '').replace(/\.home$/i, '')} (${member})` + : personLabel, home, + ...(perBot ? { handle: member } : {}), // Auto-send for this pair (this Bot → that person), shown and changed on the row (#417). ...(home ? { person: view.person || home, autoSend: approvalFor(botId, home) === 'auto' } : {}), relationshipId: grant.relationshipId, diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 11fd44c59..02c89311c 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -5306,8 +5306,10 @@ test('social/access labels an object-shaped list_bot_grants grantee, not @object const row = access.grants.find(g => g.home === '@bob.home' || g.grantee === '@bob.home' || /coder\.bob/.test(String(g.grantee))) assert.ok(row, `expected a Bob grant, got ${JSON.stringify(access.grants)}`) assert.equal(row.home, '@bob.home') - // The owner's display name from Bot Social, not the handle in lowercase (#275). - assert.equal(row.name, 'Bob (@bob.home)') + // The Bot the grant is for is named, the way the share list names it (#995); the owner keeps the name + // Bot Social sent (#275), not the handle in lowercase. + assert.equal(row.name, 'Coder (Bob)') + assert.equal(row.handle, '@coder.bob.home') assert.doesNotMatch(String(row.name), /object/i) assert.doesNotMatch(String(row.grantee), /object/i) }) @@ -6158,13 +6160,15 @@ test('#383: a possible talk-through grant from an older join keeps its own row, assert.equal(revoked.some(args => args.includes('g-tt')), false, 'the chat with Helper keeps working') }) -test('#383: People with access shows one row per person; its Revoke removes each of their grants', async (t) => { +test('#383 + #995: People with access is one row per Bot; a row keeps the batch Revoke for its own grants', async (t) => { const { ctx, call, home, stop } = await team() t.after(stop) const server = mockServer() - // Two invites for our Chief, joined by two of Bob's Bots: two real shares to one person. + // Two invites for our Chief, joined by two of Bob's Bots: two real shares to one person, one per Bot + // (#995). The first Bot holds two invites' grants, so a row can still be a batch (#383). let listed = [ { id: 'g-1', grantee: '@chief.bob.home', name: 'Bob (@bob.home)', relationship_id: 'r1' }, + { id: 'g-1b', grantee: '@chief.bob.home', name: 'Bob (@bob.home)', relationship_id: 'r2' }, { id: 'g-2', grantee: '@coder.bob.home', name: 'Bob (@bob.home)', relationship_id: 'r3' }, { id: 'g-3', grantee: '@carol.home', name: 'Carol (@carol.home)', relationship_id: 'r4' }, ] @@ -6181,9 +6185,13 @@ test('#383: People with access shows one row per person; its Revoke removes each await social.registerAll() const chief = ctx.flybot.bot.list().find(bot => bot.name === 'Chief') const access = (await call('flybot/social/access', { botId: chief.id })).value - assert.deepEqual(access.grants.map(row => row.grantee), ['@bob.home', '@carol.home']) + // One row per Bot (#995), and Carol's person-level grant keeps the row it had. + assert.deepEqual(access.grants.map(row => [row.handle ?? null, row.home, row.grantIds]), [ + ['@chief.bob.home', '@bob.home', ['g-1', 'g-1b']], + ['@coder.bob.home', '@bob.home', ['g-2']], + [null, '@carol.home', ['g-3']], + ]) const bob = access.grants[0] - assert.deepEqual(bob.grantIds, ['g-1', 'g-2']) const before = server.calls.length assert.equal((await call('flybot/social/revoke-grant', { botId: chief.id, grantId: bob.id, grantIds: bob.grantIds })).ok, true) // One roster refresh for the whole batch, started after the last delete (Codex P1). @@ -6192,11 +6200,12 @@ test('#383: People with access shows one row per person; its Revoke removes each const lastDelete = batch.findLastIndex(name => /revoke|DELETE/i.test(name)) assert.equal(batch.slice(0, lastDelete).includes('list_remote_bots'), false, 'no refresh between the deletes') const revoked = server.calls.slice(before).filter(c => /revoke|DELETE/i.test(c.name)).map(c => JSON.stringify(c.args)) - assert.equal(revoked.some(args => args.includes('g-1')), true) - assert.equal(revoked.some(args => args.includes('g-2')), true) + assert.equal(revoked.some(args => args.includes('g-1"')), true) + assert.equal(revoked.some(args => args.includes('g-1b')), true) + assert.equal(revoked.some(args => args.includes('g-2')), false, "Bob's other Bot keeps its access") assert.equal(revoked.some(args => args.includes('g-3')), false, 'Carol keeps her access') - listed = listed.filter(grant => grant.id === 'g-3') - assert.deepEqual((ctx.flybot.grants.of(chief.id) ?? []).map(grant => grant.id), ['g-3']) + listed = listed.filter(grant => grant.id === 'g-2' || grant.id === 'g-3') + assert.deepEqual((ctx.flybot.grants.of(chief.id) ?? []).map(grant => grant.id), ['g-2', 'g-3']) }) // #495: both friends joined each other's invite, so Bot Social regranted the second invite into the FIRST @@ -6299,7 +6308,7 @@ test('#495: a reply-only grant alone is never listed as a share, even with no jo assert.equal((ctx.flybot.grants.of(chief.id) ?? []).length, 0, 'the reply-only grant is not stored as ours') }) -test('#495: with reply_only, the #383 case keeps one row per person and Revoke leaves the talk-through grant', async (t) => { +test('#495: with reply_only, the #383 case keeps one row per Bot and Revoke leaves the talk-through grant', async (t) => { const { ctx, call, home, stop } = await team() t.after(stop) const server = mockServer() @@ -6334,13 +6343,14 @@ test('#495: with reply_only, the #383 case keeps one row per person and Revoke l await social.remoteBots({ fresh: true }) const chief = ctx.flybot.bot.list().find(bot => bot.name === 'Chief') const access = (await call('flybot/social/access', { botId: chief.id })).value - assert.deepEqual(access.grants.map(row => [row.grantee, row.grantIds]), [['@bob.home', ['g-share', 'g-share-2']]], 'one row for Bob') + assert.deepEqual(access.grants.map(row => [row.handle, row.grantIds]), [['@chief.bob.home', ['g-share']], ['@helper.bob.home', ['g-share-2']]], 'one row per Bot (#995)') assert.equal((ctx.flybot.grants.of(chief.id) ?? []).some(grant => grant.id === 'g-tt'), false, 'the talk-through grant is not stored as ours') const before = server.calls.length - assert.equal((await call('flybot/social/revoke-grant', { botId: chief.id, grantId: access.grants[0].id, grantIds: access.grants[0].grantIds })).ok, true) + // Revoking Bob's Helper takes that Bot's share only; the chat with Bob's Chief keeps working. + assert.equal((await call('flybot/social/revoke-grant', { botId: chief.id, grantId: access.grants[1].id, grantIds: access.grants[1].grantIds })).ok, true) const revoked = server.calls.slice(before).filter(c => /revoke|DELETE/i.test(c.name)).map(c => JSON.stringify(c.args)) - assert.equal(revoked.some(args => args.includes('g-share"')), true) assert.equal(revoked.some(args => args.includes('g-share-2')), true) + assert.equal(revoked.some(args => args.includes('g-share"')), false, "Bob's Chief keeps the share") assert.equal(revoked.some(args => args.includes('g-tt')), false, 'the chat with Helper keeps working') }) @@ -6381,10 +6391,11 @@ test('#383: a grant list taken while a merged Revoke runs is not written back (M const server = mockServer() let listed = [ { id: 'g-1', grantee: '@chief.bob.home', name: 'Bob (@bob.home)', relationship_id: 'r1' }, + { id: 'g-1b', grantee: '@chief.bob.home', name: 'Bob (@bob.home)', relationship_id: 'r2' }, { id: 'g-2', grantee: '@coder.bob.home', name: 'Bob (@bob.home)', relationship_id: 'r3' }, { id: 'g-3', grantee: '@carol.home', name: 'Carol (@carol.home)', relationship_id: 'r4' }, ] - // The next list answer is held: it is taken now (all three grants) and lands only when released. + // The next list answer is held: it is taken now (all four grants) and lands only when released. let hold server.tools.list_bot_grants = () => { const snapshot = { grants: [...listed] } @@ -6403,7 +6414,7 @@ test('#383: a grant list taken while a merged Revoke runs is not written back (M await social.registerAll() const chief = ctx.flybot.bot.list().find(bot => bot.name === 'Chief') const bob = (await call('flybot/social/access', { botId: chief.id })).value.grants[0] - assert.deepEqual(bob.grantIds, ['g-1', 'g-2']) + assert.deepEqual(bob.grantIds, ['g-1', 'g-1b'], "Bob's first Bot holds both of its grants (#995 row, #383 batch)") // The open dialog's 5-second loader asks again; its list is in flight when Revoke starts. hold = {} const loader = call('flybot/social/access', { botId: chief.id }) @@ -6415,14 +6426,14 @@ test('#383: a grant list taken while a merged Revoke runs is not written back (M server.tools.revoke_bot_grant = (args) => { listed = listed.filter(grant => grant.id !== args.grant_id); return { ok: true } } const batch = await call('flybot/social/revoke-grant', { botId: chief.id, grantId: bob.id, grantIds: bob.grantIds }) assert.equal(batch.ok, true) - listed = listed.filter(grant => grant.id === 'g-3') + listed = listed.filter(grant => grant.id === 'g-2' || grant.id === 'g-3') for (let n = 0; n < 20; n++) await new Promise(resolve => setTimeout(resolve, 5)) - assert.deepEqual((ctx.flybot.grants.of(chief.id) ?? []).map(grant => grant.id), ['g-3']) - // The stale answer (still naming g-1 and g-2) lands after the batch: it must not bring Bob back. + assert.deepEqual((ctx.flybot.grants.of(chief.id) ?? []).map(grant => grant.id), ['g-2', 'g-3']) + // The stale answer (still naming g-1 and g-1b) lands after the batch: it must not bring them back. release() assert.equal((await loader).ok, true) for (let n = 0; n < 20; n++) await new Promise(resolve => setTimeout(resolve, 5)) - assert.deepEqual((ctx.flybot.grants.of(chief.id) ?? []).map(grant => grant.id), ['g-3'], 'Bob stays revoked') + assert.deepEqual((ctx.flybot.grants.of(chief.id) ?? []).map(grant => grant.id), ['g-2', 'g-3'], "Bob's first Bot stays revoked") }) test('#140: respond Auto-send follows the accepted relationship, not the first roster home', async (t) => { @@ -11258,6 +11269,104 @@ test('#991: a held room line names the room it will go to, so two open rooms are assert.equal(approvalView({ approval_id: 'apr-old', kind: 'face_room_message' }).toName, 'the room') }) +// #995: sharing is per Bot (#973), so People with access has to be too. Two Bot-level grants to one +// friend were a single row keyed by the person's home, and its Revoke deleted both. +function perBotGrants() { + let grants = [ + { grant_id: 'g-coder', grantee: { bot_id: 'x1', handle: '@coder.bob.home', display_name: 'Coder', owner_display_name: 'Bob' }, relationship_id: 'r1' }, + { grant_id: 'g-writer', grantee: { bot_id: 'x2', handle: '@writer.bob.home', display_name: 'Writer', owner_display_name: 'Bob' }, relationship_id: 'r1' }, + ] + return { + listed: () => grants.map(grant => ({ ...grant })), + revoke: (grantId) => { grants = grants.filter(grant => grant.grant_id !== grantId); return { grants } }, + left: () => grants.map(grant => grant.grant_id), + } +} + +test('#995: People with access is one row per Bot, and Revoke takes only that row', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = mockServer() + const state = perBotGrants() + server.tools.list_bot_grants = () => ({ grants: state.listed() }) + server.tools.revoke_bot_grant = a => state.revoke(a.grant_id) + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, 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 access = (await call('flybot/social/access', { botId: 'b' })).value + // One row per Bot, labelled the way the share list labels it (#973), each carrying its own grant. + assert.deepEqual(access.grants.map(row => row.name), ['Coder (Bob)', 'Writer (Bob)']) + assert.deepEqual(access.grants.map(row => row.grantIds), [['g-coder'], ['g-writer']]) + assert.deepEqual(access.grants.map(row => row.handle), ['@coder.bob.home', '@writer.bob.home']) + assert.deepEqual(access.grants.map(row => row.home), ['@bob.home', '@bob.home']) + // Revoke as the panel does it: that row's grant only. The other Bot keeps its access. + const first = access.grants[0] + await call('flybot/social/revoke-grant', { botId: 'b', grantId: first.id, relationshipId: first.relationshipId, grantee: first.grantee }) + assert.deepEqual(state.left(), ['g-writer'], 'one Revoke took one Bot, not the friend') + const after = (await call('flybot/social/access', { botId: 'b' })).value + assert.deepEqual(after.grants.map(row => row.name), ['Writer (Bob)']) +}) + +test('#995: a single-Bot friend is still one row and one Revoke, and a person-level grant keeps its row', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = mockServer() + let grants = [{ grant_id: 'g-coder', grantee: { bot_id: 'x1', handle: '@coder.bob.home', display_name: 'Coder', owner_display_name: 'Bob' }, relationship_id: 'r1' }] + server.tools.list_bot_grants = () => ({ grants }) + server.tools.revoke_bot_grant = a => { grants = grants.filter(grant => grant.grant_id !== a.grant_id); return { grants } } + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, 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 one = (await call('flybot/social/access', { botId: 'b' })).value.grants + assert.deepEqual(one.map(row => [row.name, row.grantIds]), [['Coder (Bob)', ['g-coder']]]) + // A grant that names no Bot (an older person-level share) keeps its own row, as #981 decided for the + // share list: there is no Bot to name, so it is not merged into a Bot row that might be someone else. + grants = [...grants, { grant_id: 'g-person', grantee: '@bob.home', name: 'Bob (@bob.home)', relationship_id: 'r1' }] + const both = (await call('flybot/social/access', { botId: 'b' })).value.grants + assert.deepEqual(both.map(row => row.name), ['Coder (Bob)', 'Bob (@bob.home)']) + assert.equal(both[1].handle, undefined, 'only a per-Bot row names a Bot') +}) + +test('#995: the access panel draws one Revoke per Bot, one Auto-send per person, and the Bot handle', async () => { + const calls = [] + const row = (id, name, handle, grantId) => ({ id: grantId, grantIds: [grantId], grantee: '@bob.home', name, home: '@bob.home', handle, person: 'Bob', autoSend: false, relationshipId: 'r1', rooms: [] }) + const payload = { + botId: 'b', botName: 'Chief', granted: true, rooms: [], + grants: [row('g-coder', 'Coder (Bob)', '@coder.bob.home', 'g-coder'), row('g-writer', 'Writer (Bob)', '@writer.bob.home', 'g-writer')], + } + const { walk, pluginOf } = loadSocialClient(async (endpoint, body) => { + calls.push([endpoint, body]) + if (endpoint === 'social/access') return payload + return { botId: 'b', revoked: body?.grantId, grants: [payload.grants[1]] } + }) + const plugin = pluginOf([payload, '', '', 0]) + const tree = walk(plugin.ManageAccess({ bot: { id: 'b', name: 'Chief' } })) + const rows = tree.filter(node => node.props['data-flybot'] === 'access-grant') + assert.equal(rows.length, 2) + assert.deepEqual(tree.filter(node => node.props.className === 'fs-name' || node.className === 'fs-name').map(node => node.children?.[0]).filter(value => typeof value === 'string'), ['People with access', 'Coder (Bob)', 'Writer (Bob)']) + // Each row carries the Bot's own handle, not the shared home twice. + assert.deepEqual(tree.filter(node => node.props.className === 'fs-sub').map(node => node.children?.[0]), ['@coder.bob.home', '@writer.bob.home']) + // Auto-send is one setting for the pair: one switch, on the first of the friend's rows. + const autos = tree.filter(node => node.props['data-flybot'] === 'access-auto-send') + assert.equal(autos.length, 1) + assert.ok(rows[0].children.some(node => node?.props?.['data-flybot'] === 'access-auto-send')) + assert.equal(rows[1].children.some(node => node?.props?.['data-flybot'] === 'access-auto-send'), false) + const revokes = tree.filter(node => node.props['data-flybot'] === 'revoke-grant') + assert.equal(revokes.length, 2) + await revokes[1].props.onClick() + const sent = calls.find(([endpoint]) => endpoint === 'social/revoke-grant') + assert.equal(sent[1].grantId, 'g-writer') + assert.equal('grantIds' in sent[1], false, 'a one-grant row revokes that grant alone') +}) + // #994: on this deployment `grant_bot_to_friend` answers with the grant id and the status only — no `friend`, // no `grantee` — so the receipt fell back to the home key's slug ("selftestb2"), which names the person's // home instead of the Bot the click granted. Two rows make that the one line that cannot be wrong.