From ba0efa27977c7063a3b4dfdbc7eb967d18c4b764 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 23:08:28 +0800 Subject: [PATCH] Name the Bot that got the share in the receipt (#994) Bot Social's grant_bot_to_friend answer on this deployment carries neither the friend nor the grantee name, so the receipt fell back to the home key and printed its slug: "Shared Chief with selftestb2." With one row per Bot that is the one line that cannot be wrong, so the row label the panel already draws travels with the click (`friendName`) and the receipt uses it; a caller that sends no label gets "with your friend", never the slug. --- .../994-share-receipt-names-the-bot.fixed.md | 1 + docs/subsystems/social.md | 2 +- plugins/social/client.js | 2 + plugins/social/src/social.js | 11 ++++- plugins/social/test/social.test.js | 42 ++++++++++++++++++- 5 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 changelog.d/994-share-receipt-names-the-bot.fixed.md diff --git a/changelog.d/994-share-receipt-names-the-bot.fixed.md b/changelog.d/994-share-receipt-names-the-bot.fixed.md new file mode 100644 index 000000000..d284a3973 --- /dev/null +++ b/changelog.d/994-share-receipt-names-the-bot.fixed.md @@ -0,0 +1 @@ +The share receipt names the Bot the click granted — `Shared Chief with Writer (SelfTest B2).` — using the label the row already shows, instead of falling back to the friend's home slug (`Shared Chief with selftestb2.`) when Bot Social's answer carries no names; a caller that sends no label gets a plain `with your friend`, never the slug ([#994](https://github.com/ThinkFlowLab/FlyBot/issues/994)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 19ed12c74..99ab93626 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); 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 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). ## Owns diff --git a/plugins/social/client.js b/plugins/social/client.js index a49ff9e04..545840cd5 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -3195,6 +3195,8 @@ window.__ModuleLoader__.load({ botId: bot.id, home: friend.home, ...(friend.peerBotId ? { friendBotId: friend.peerBotId } : {}), + // #994: the receipt says who got it; the label the row already shows goes with the click. + ...(friend.name ? { friendName: friend.name } : {}), ...(friend.ownerId ? { ownerId: friend.ownerId } : {}), }) setNote({ tone: 'ok', text: result?.message || `Shared ${bot.name}.` }) diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index b97f2a99e..050b0f0ca 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -2694,7 +2694,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // #500: grant this Bot to an existing friend (Bot Social grant_bot_to_friend). Does not // replace invite links. After a successful share, People with access re-syncs this Bot. - disposers.push(flybot.api.handle('social/share-with-friend', async ({ botId, home: friendHome, ownerId, friendOwnerId, friendBotId, peerBotId } = {}) => { + disposers.push(flybot.api.handle('social/share-with-friend', async ({ botId, home: friendHome, ownerId, friendOwnerId, friendBotId, peerBotId, friendName } = {}) => { if (!home) throw new Error('Bot Social is not connected: there is no FlyBot home yet') const bot = typeof botId === 'string' && botId ? flybot.bot.get(botId) : undefined if (!bot) throw new Error('Choose one of your Bots to share') @@ -2735,8 +2735,15 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const friend = result?.friend ?? {} const botName = cleanText(friend.display_name) const ownerName = cleanText(friend.owner_display_name || result?.grantee?.owner_display_name) + // #994: the answer from this deployment carries neither name, and the home key's slug ("selftestb2") is + // not a name: with one row per Bot it names the person's home instead of the Bot that got the grant. The + // panel already draws that Bot's label, so the click carries it (`friendName`); a caller without one gets a + // plain line, never the slug. + const slug = homeKey ? String(homeKey).replace(/^@/, '').replace(/\.home$/i, '') : '' + const rowLabel = cleanText(friendName) const who = (botName ? (ownerName ? `${botName} (${ownerName})` : botName) : ownerName) - || (homeKey ? (String(homeKey).replace(/^@/, '').replace(/\.home$/i, '') || homeKey) : 'your friend') + || (rowLabel && rowLabel.toLowerCase() !== slug.toLowerCase() ? rowLabel : '') + || 'your friend' return { botId: bot.id, botName: bot.name, diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index fccbd68c1..ae0d963a6 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -9490,7 +9490,7 @@ test('#973: the panel draws one row per Bot and names the Bot each button shares assert.match(text(card), /Writer \(Bob\)|@writer\.bob\.home/, 'the row shows which Bot it is') await buttons[1].props.onClick() await new Promise(r => setTimeout(r, 0)) - assert.ok(calls.some(([ep, payload]) => ep === 'social/share-with-friend' && payload.friendBotId === 'pb-writer')) + assert.ok(calls.some(([ep, payload]) => ep === 'social/share-with-friend' && payload.friendBotId === 'pb-writer' && payload.friendName === 'Writer (Bob)'), 'the row label travels with the click (#994)') }) // ---- #773: a room code shown as a QR image, and scanning one back ---------------------- @@ -11211,3 +11211,43 @@ test('#991: a held room line names the room it will go to, so two open rooms are // `approvalView` keeps its fallback for a card built by an older shape that carries no room. assert.equal(approvalView({ approval_id: 'apr-old', kind: 'face_room_message' }).toName, 'the room') }) + +// #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. +test('#994: the share receipt names the Bot the click granted and never the home slug', async (t) => { + const { ctx, home, call, stop } = await team() + t.after(stop) + const server = mockServer() + server.tools.list_friends = () => ({ + bot: { handle: '@danyang.home' }, + friends: [ + { relationship_id: 'r-chief', status: 'active', peer: { bot_id: 'pb-chief', handle: '@chief.selftestb2.home', display_name: 'Chief', owner_display_name: 'SelfTest B2' } }, + { relationship_id: 'r-writer', status: 'active', peer: { bot_id: 'pb-writer', handle: '@writer.selftestb2.home', display_name: 'Writer', owner_display_name: 'SelfTest B2' } }, + ], + pending_incoming: [], + pending_outgoing: [], + }) + server.tools.list_bot_grants = ({ bot_id }) => ({ bot: { bot_id }, grants: [] }) + // What the real server answers today: the grant and its status, no names. + server.tools.grant_bot_to_friend = () => ({ grant_id: 'gr_1', status: 'active' }) + const social = await startSocial(ctx.flybot, { + server: 'https://social.test', credentialsPath: join(home, 'flybot-social.json'), + fetch: server.fetch, inboxPollMs: 0, + }) + t.after(() => social.dispose()) + await settle() + const chief = ctx.flybot.bot.list().find(b => b.isMain) || ctx.flybot.bot.list()[0] + await call('flybot/social/create-invite', { botId: chief.id }) + const listing = (await call('flybot/social/share-friends', { botId: chief.id })).value + const writer = listing.friends.find(f => f.peerBotId === 'pb-writer') + assert.equal(writer.name, 'Writer (SelfTest B2)', 'the row already names the Bot and its owner') + // The click the panel makes: the row label travels with the id of the Bot it shares with. + const shared = (await call('flybot/social/share-with-friend', { botId: chief.id, home: writer.home, peerBotId: writer.peerBotId, friendName: writer.name })).value + assert.equal(shared.message, 'Shared Chief with Writer (SelfTest B2). They can decline; you can Revoke in People with access.') + assert.doesNotMatch(shared.message, /selftestb2/i, 'the home slug never reaches the receipt (#994)') + // A caller with no label (an older client) gets a plain line, still never the slug. + const bare = (await call('flybot/social/share-with-friend', { botId: chief.id, home: writer.home, peerBotId: 'pb-writer' })).value + assert.equal(bare.message, 'Shared Chief with your friend. They can decline; you can Revoke in People with access.') + assert.doesNotMatch(bare.message, /selftestb2/i) +})