diff --git a/changelog.d/987-share-error-not-list-error.fixed.md b/changelog.d/987-share-error-not-list-error.fixed.md new file mode 100644 index 000000000..ea4a521ce --- /dev/null +++ b/changelog.d/987-share-error-not-list-error.fixed.md @@ -0,0 +1 @@ +When sharing a Bot with a friend fails, the Sharing card keeps that message as the share's own: it no longer offers a **Try again** that only reloads the friend list, and a later successful reload no longer wipes it. The friend-list read keeps its retry and its empty state, and the two errors no longer stand in for each other ([#987](https://github.com/ThinkFlowLab/FlyBot/issues/987)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index d4faeb06c..6efc067e0 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); **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); 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): the status slot carries a source, the retry button belongs to the list read alone and it is the only failure a successful read clears, 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 5382b836e..0c08a7241 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -3161,12 +3161,13 @@ window.__ModuleLoader__.load({ // about a roster that no longer exists, so it is dropped, never written (#984). if (readSeq.current !== seq || shareListEpoch !== epoch) return setFriends(Array.isArray(next?.friends) ? next.friends : []) - setNote(previous => (previous?.tone === 'error' ? null : previous)) + // #987: the note has a source. A successful read settles the list, never a share's error. + setNote(previous => (previous?.source === 'list' ? null : previous)) } catch (e) { if (readSeq.current !== seq || shareListEpoch !== epoch) return // Keep the rows a successful read established: a failed read established nothing, so the // card says it failed and offers a retry instead of claiming nobody is left (#984). - setNote({ tone: 'error', text: errorText(e) }) + setNote({ tone: 'error', source: 'list', text: errorText(e) }) } } useEffect(() => { @@ -3192,12 +3193,13 @@ window.__ModuleLoader__.load({ ...(friend.peerBotId ? { friendBotId: friend.peerBotId } : {}), ...(friend.ownerId ? { ownerId: friend.ownerId } : {}), }) - setNote({ tone: 'ok', text: result?.message || `Shared ${bot.name}.` }) + setNote({ tone: 'ok', source: 'share', text: result?.message || `Shared ${bot.name}.` }) const next = await call('social/share-friends', { botId: bot.id }).catch(() => null) if (next) setFriends(Array.isArray(next.friends) ? next.friends : []) else setFriends(list => (list ?? []).filter(item => friendKey(item) !== friendKey(friend))) } catch (e) { - setNote({ tone: 'error', text: errorText(e) }) + // #987: the share's failure speaks for the share; it is not the list's error. + setNote({ tone: 'error', source: 'share', text: errorText(e) }) } finally { setBusy('') } } const list = friends ?? [] @@ -3206,7 +3208,7 @@ window.__ModuleLoader__.load({ h('div', { className: 'fs-name' }, 'Share with a friend'), h('div', { className: 'fs-hint' }, `One click shares ${bot.name || 'this Bot'} with someone you already know. No invite link. They can decline; you can Revoke in People with access.`), loading ? h('div', { className: 'fs-hint' }, 'Loading friends…') : null, - !loading && list.length === 0 && note?.tone !== 'error' + !loading && list.length === 0 && note?.source !== 'list' ? h('div', { className: 'fs-hint', 'data-flybot': 'share-with-friend-empty' }, 'No friends left to share with. Invite someone new above, or they already have this Bot.') : null, list.map(friend => h('div', { key: friendKey(friend), className: 'fs-line', 'data-flybot': 'share-friend-row' }, @@ -3224,7 +3226,7 @@ window.__ModuleLoader__.load({ onClick: () => { void share(friend) }, }, busy === friendKey(friend) ? 'Sharing…' : 'Share'))), note ? h('div', { className: 'fs-status', 'data-tone': note.tone, role: 'status' }, note.text, - note.tone === 'error' + note.tone === 'error' && note.source === 'list' ? h('button', { type: 'button', className: 'fs-link', 'data-flybot': 'share-list-retry', onClick: () => { void load() } }, 'Try again') : null) : null) } diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index ee7124e31..e00ab2d94 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -11017,3 +11017,69 @@ test('#984: a share-list read that straddled a revoke is dropped, not written', assert.ok(share.has('share-friend-row'), 'a read taken across the revoke does not empty the card (#984)') assert.equal(share.has('share-with-friend-empty'), false) }) + +// #987: the note has a source. A share failure and a list failure share one slot, so the retry +// belongs to the list read and a successful read clears only a list error. +test('#987: a failed share keeps its own error and offers no list retry', async () => { + const host = sharingHost(async (endpoint) => { + if (endpoint === 'social/share-friends') return { friends: [shareCoder] } + if (endpoint === 'social/share-with-friend') throw new Error('social/share-with-friend: Bot Social said no') + return {} + }) + const panel = host.mount('ShareWithFriend', { bot: { id: 'b', name: 'Writer' } }) + await panel.settle() + await panel.click('share-friend') + await panel.settle() + assert.match(panel.text(), /Bot Social said no/, 'the share failure is shown') + assert.equal(panel.has('share-list-retry'), false, 'a share failure must not offer the list reader\'s retry') + panel.poll() + await panel.settle() + assert.match(panel.text(), /Bot Social said no/, 'a successful list read clears only a list error') +}) + +test('#987: a failed list read still offers a retry that re-reads and clears its own error', async () => { + let readFails = true + let reads = 0 + const host = sharingHost(async (endpoint) => { + if (endpoint !== 'social/share-friends') return {} + reads += 1 + if (readFails) throw new Error('social/share-friends: no answer from Bot Social') + return { friends: [shareCoder] } + }) + const panel = host.mount('ShareWithFriend', { bot: { id: 'b', name: 'Writer' } }) + await panel.settle() + assert.ok(panel.has('share-list-retry'), 'a list failure offers the retry') + const before = reads + readFails = false + await panel.click('share-list-retry') + await panel.settle() + assert.equal(reads, before + 1, 'the retry re-reads the list') + assert.equal(panel.has('share-list-retry'), false, 'a successful read clears the list error') + assert.ok(panel.has('share-friend-row')) +}) + +test('#987: the empty sentence is suppressed by a list error, not by a share error', async () => { + let friends = [shareCoder] + let readFails = false + const host = sharingHost(async (endpoint) => { + if (endpoint === 'social/share-friends') { + if (readFails) throw new Error('social/share-friends: no answer from Bot Social') + return { friends } + } + if (endpoint === 'social/share-with-friend') throw new Error('social/share-with-friend: Bot Social said no') + return {} + }) + const panel = host.mount('ShareWithFriend', { bot: { id: 'b', name: 'Writer' } }) + await panel.settle() + await panel.click('share-friend') + await panel.settle() + friends = [] + panel.poll() + await panel.settle() + assert.ok(panel.has('share-with-friend-empty'), 'a share error does not speak for the list') + assert.match(panel.text(), /Bot Social said no/) + readFails = true + panel.poll() + await panel.settle() + assert.equal(panel.has('share-with-friend-empty'), false, 'a list error does suppress it') +})