From 808497050bc4dc65f9101e1e031a6d39ac260b99 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Wed, 21 Oct 2026 17:18:55 +0800 Subject: [PATCH] fix(social): share a Bot with a friend by the peer Bot id (#592) `list_friends` hands the plugin each peer's `bot_id` and never its `owner_id`, while Bot Social's `grant_bot_to_friend` only accepted `friend_owner_id`, so "share with a friend" could only ever be refused. - the plugin now sends `friend_bot_id`, taken from the peer the friend list already carries; - the REST fallback posts to the route the server really has, `POST /v1/bots/:id/grants` (it was `/grants/to-friend`), with the same two fields, so it works against either server build; - a share still refused as invalid input now reads as one plain line ("Its server may need an update; invite links still work.") instead of the raw JSON-RPC text, and the last-resort fallback no longer leaks protocol text at all; - the plugin's test server serves the real peer shape (a `bot_id`, no `owner_id`) on the real route, so the mocks can no longer pass while the connector fails. Reverting the three source files and keeping the tests turns them red. --- .../592-share-with-friend-peer-bot.fixed.md | 1 + docs/subsystems/social.md | 2 +- plugins/social/client.js | 1 + plugins/social/src/mcp-client.js | 15 ++-- plugins/social/src/social.js | 20 ++++-- plugins/social/test/social.test.js | 70 ++++++++++++++----- 6 files changed, 80 insertions(+), 29 deletions(-) create mode 100644 changelog.d/592-share-with-friend-peer-bot.fixed.md diff --git a/changelog.d/592-share-with-friend-peer-bot.fixed.md b/changelog.d/592-share-with-friend-peer-bot.fixed.md new file mode 100644 index 000000000..cc767fd7d --- /dev/null +++ b/changelog.d/592-share-with-friend-peer-bot.fixed.md @@ -0,0 +1 @@ +Sharing a Bot with a friend works again: **Share with a friend** names the friend by the friend's Bot id (the id `list_friends` returns) instead of an owner id the server never lists, the fallback route now matches Bot Social (`POST /v1/bots/:id/grants`), and a server that refuses the request says so in plain words instead of showing the raw `MCP error -32602` text ([#592](https://github.com/ThinkFlowLab/FlyBot/issues/592)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 3389abe63..78a04574c 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -10,7 +10,7 @@ The social plugin talks to a [Bot Social](https://github.com/Birfy/bot-social) s Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`. Inbound friend messages arrive immediately; outbound drafts the server holds (`pending_approval`) are listed on `social/status` and approved in the open chat (`flybot.session.dock`). The social sidebar strip shows a badge and a one-line jump list. The same queue is also attached to a native shared room via `flybot.roomApprovals.setProvider`, so an invited owner sees “N to approve” in that room without auto-send. -Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, after New group chat; `label` / `hint` use the same `t()` map). **Face-to-face room** and **Add friend** are those rows (copying an invite and your own handle live inside Add friend); the social strip has no **+ Add** button. **Face-to-face room** opens the same screen (pick one or more local Bots, six-digit number, live waiting room that shows the code, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its published look or a handle-seeded look, plus any avatar face-room `members[]` carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief · BoxB`); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** for face rooms is one compact checkbox (tooltip) on the open room's overview, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, **main sidebar row**, and core group overview uses the host `BotAvatar` (published look, optional member `avatar`, else a handle-seeded look). **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; a server without that tool tries the REST route `POST /v1/bots/:id/grants/to-friend` 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); **People with access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** declines that grant on Bot Social (`decline_bot_grant`) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle, or an explicit re-share / `regranted` join. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). +Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, after New group chat; `label` / `hint` use the same `t()` map). **Face-to-face room** and **Add friend** are those rows (copying an invite and your own handle live inside Add friend); the social strip has no **+ Add** button. **Face-to-face room** opens the same screen (pick one or more local Bots, six-digit number, live waiting room that shows the code, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its published look or a handle-seeded look, plus any avatar face-room `members[]` carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief · BoxB`); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** for face rooms is one compact checkbox (tooltip) on the open room's overview, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, **main sidebar row**, and core group overview uses the host `BotAvatar` (published look, optional member `avatar`, else a handle-seeded look). **Sharing → Copy invite link** creates a per-Bot grant when the friend accepts; **Share with a friend** (#500) lists current friends who do not have this Bot yet and grants it in one click (Bot Social `grant_bot_to_friend`, companion Birfy/bot-social#34) without an invite link; 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**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** declines that grant on Bot Social (`decline_bot_grant`) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle, or an explicit re-share / `regranted` join. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). ## Owns diff --git a/plugins/social/client.js b/plugins/social/client.js index 277fbba8e..337515585 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -2130,6 +2130,7 @@ window.__ModuleLoader__.load({ const result = await call('social/share-with-friend', { botId: bot.id, home: friend.home, + ...(friend.peerBotId ? { friendBotId: friend.peerBotId } : {}), ...(friend.ownerId ? { ownerId: friend.ownerId } : {}), }) setNote({ tone: 'ok', text: result?.message || `Shared ${bot.name}.` }) diff --git a/plugins/social/src/mcp-client.js b/plugins/social/src/mcp-client.js index 8b9378b16..4ec442358 100644 --- a/plugins/social/src/mcp-client.js +++ b/plugins/social/src/mcp-client.js @@ -433,21 +433,24 @@ export function createSocialApi({ server, token, fetch: fetchImpl = socialFetch, // Owner grants one of THEIR bots to an existing friend (Birfy/bot-social#34 / FlyBot #500). // Same per-Bot grant an accepted invite creates; no invite code. Idempotent when a live grant - // already exists. Prefer MCP grant_bot_to_friend; REST POST …/grants/to-friend is the fallback. - // friend_owner_id is the companion's preferred key; friend_home is what FlyBot always has. - async function grantBotToFriend({ botId, friendOwnerId, friendHome } = {}) { + // already exists. Prefer MCP grant_bot_to_friend; REST POST …/grants is the fallback. + // friend_bot_id is the peer's bot_id from list_friends (#592) and is what the server resolves the + // friend owner from; friend_owner_id is kept for a server that only knows it. + async function grantBotToFriend({ botId, friendOwnerId, friendHome, friendBotId } = {}) { if (!botId) throw new BotSocialError('Bot Social grant_bot_to_friend: which Bot?', 'invalid') - if (!friendOwnerId && !friendHome) { + if (!friendOwnerId && !friendHome && !friendBotId) { throw new BotSocialError('Bot Social grant_bot_to_friend: which friend?', 'invalid') } const args = { bot_id: botId, + ...(friendBotId ? { friend_bot_id: friendBotId } : {}), ...(friendOwnerId ? { friend_owner_id: friendOwnerId } : {}), ...(friendHome ? { friend_home: friendHome } : {}), } + // The server takes these two only; friend_home is a FlyBot-side name for the same friend. const body = { + ...(friendBotId ? { friend_bot_id: friendBotId } : {}), ...(friendOwnerId ? { friend_owner_id: friendOwnerId } : {}), - ...(friendHome ? { friend_home: friendHome } : {}), } const viaRest = async (error) => { if (!base) { @@ -455,7 +458,7 @@ export function createSocialApi({ server, token, fetch: fetchImpl = socialFetch, throw error ?? new BotSocialError('Bot Social grant_bot_to_friend: no server URL', 'no_server') } try { - return await rest('POST', `/v1/bots/${encodeURIComponent(botId)}/grants/to-friend`, body) + return await rest('POST', `/v1/bots/${encodeURIComponent(botId)}/grants`, body) } catch (restErr) { if (isUnknownTool(restErr) || isMissingEndpoint(restErr)) return { unavailable: true } throw restErr diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 495423a99..d3ed7cc85 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -1851,7 +1851,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp return client.callTool(tool, { ...args, confirm_code: first.confirm_code }) } const homeArgs = () => (home ? { bot_id: home.id } : config.botId ? { bot_id: config.botId } : {}) - const peerView = item => ({ relationshipId: item.relationship_id, status: item.status, handle: item.peer?.handle ? norm(item.peer.handle) : undefined, name: cleanText(item.peer?.display_name), owner: cleanText(item.peer?.owner_display_name), ...(item.peer?.owner_id || item.friend_owner_id || item.owner_id ? { ownerId: String(item.peer?.owner_id || item.friend_owner_id || item.owner_id) } : {}), message: item.message ?? null, requestedAt: item.requested_at, expiresAt: item.expires_at }) + const peerView = item => ({ relationshipId: item.relationship_id, status: item.status, handle: item.peer?.handle ? norm(item.peer.handle) : undefined, name: cleanText(item.peer?.display_name), owner: cleanText(item.peer?.owner_display_name), ...(item.peer?.bot_id ? { peerBotId: String(item.peer.bot_id) } : {}), ...(item.peer?.owner_id || item.friend_owner_id || item.owner_id ? { ownerId: String(item.peer?.owner_id || item.friend_owner_id || item.owner_id) } : {}), message: item.message ?? null, requestedAt: item.requested_at, expiresAt: item.expires_at }) // A friendship may hang on the home or on one member (a per-Bot invite, #420), so the home and every // member identity are asked, as setAutoAccept does; one relationship is listed once. A member that // cannot be read costs only its own rows; the home's answer still decides whether this fails. @@ -2223,6 +2223,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp friends.push({ home: homeKey, name: label, + ...(item.peerBotId ? { peerBotId: item.peerBotId } : {}), ...(item.ownerId ? { ownerId: item.ownerId } : {}), ...(item.relationshipId ? { relationshipId: item.relationshipId } : {}), }) @@ -2253,13 +2254,21 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (/not connected|no token|no server URL/i.test(blob)) { return new Error('Bot Social is disconnected. Reconnect under Settings → Bot Social.') } + // The server has the tool but refused our arguments (an older or newer contract, #592): a plain + // line, never the raw JSON-RPC validation text. + if (/(invalid input|invalid arguments|expected string|received undefined|-32602)/i.test(blob) && !/not found|no such tool|unknown tool/i.test(blob)) { + return new Error('Bot Social did not accept that share request. Its server may need an update; invite links still work.') + } const body = raw.replace(/^Bot Social\s+\S+:\s*/i, '').trim() + if (/^(MCP error|JSON-RPC|error:)/i.test(body) || /^-?\d+ /.test(body)) { + return new Error('Could not share with that friend. Try again, or use an invite link.') + } return new Error(body || 'Could not share with that friend. Try again.') } // #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 } = {}) => { + disposers.push(flybot.api.handle('social/share-with-friend', async ({ botId, home: friendHome, ownerId, friendOwnerId, friendBotId, peerBotId } = {}) => { 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') @@ -2267,13 +2276,16 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (!member) throw new Error(`${bot.name} has no Bot Social identity yet`) const homeKey = norm(friendHome) const oid = friendOwnerId || ownerId - if (!homeKey && !oid) throw new Error('Choose a friend to share with') + // #592: the friend's Bot id, not an owner id, is what the server can resolve; owner ids are + // never listed for a friend. + const peerId = friendBotId || peerBotId + if (!homeKey && !oid && !peerId) throw new Error('Choose a friend to share with') if (typeof api.grantBotToFriend !== 'function') { throw Object.assign(new Error('Bot Social needs an update to share with a friend. Invite links still work.'), { code: 'unavailable' }) } let result try { - result = await api.grantBotToFriend({ botId: member.bot_id, friendOwnerId: oid, friendHome: homeKey || undefined }) + result = await api.grantBotToFriend({ botId: member.bot_id, friendBotId: peerId, friendOwnerId: oid, friendHome: homeKey || undefined }) } catch (error) { const mapped = mapShareFriendError(error) if (mapped) throw mapped diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 96168f2d8..eb7e06477 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -105,8 +105,8 @@ function mockServer({ token = 'owner-token', friends = true } = {}) { calls.push({ url, name: 'REST PATCH /v1/bots/profile', args: init.body ? JSON.parse(init.body) : {} }) return new Response(JSON.stringify({ error: { code: 'not_found', message: 'no such route' } }), { status: 404 }) } - if (/\/v1\/bots\/[^/]+\/grants\/to-friend/.test(String(url)) && init.method === 'POST') { - calls.push({ url, name: 'REST POST /v1/bots/grants/to-friend', args: init.body ? JSON.parse(init.body) : {} }) + if (/\/v1\/bots\/[^/]+\/grants$/.test(String(url)) && init.method === 'POST') { + calls.push({ url, name: 'REST POST /v1/bots/grants', args: init.body ? JSON.parse(init.body) : {} }) return new Response(JSON.stringify({ error: { code: 'not_found', message: 'no such route' } }), { status: 404 }) } if (String(url).includes('/v1/me')) { @@ -8297,27 +8297,27 @@ test('#500: createSocialApi grantBotToFriend prefers MCP and falls back to REST return { grant_id: 'gr_share', status: 'active', bot_id: a.bot_id, friend_home: a.friend_home } } const api = createSocialApi({ server: 'https://s', token: 'owner-token', fetch: server.fetch }) - const result = await api.grantBotToFriend({ botId: 'm1', friendHome: '@bob.home', friendOwnerId: 'o-bob' }) + const result = await api.grantBotToFriend({ botId: 'm1', friendHome: '@bob.home', friendOwnerId: 'o-bob', friendBotId: 'x1' }) assert.equal(result.grant_id, 'gr_share') - assert.deepEqual(granted[0], { bot_id: 'm1', friend_owner_id: 'o-bob', friend_home: '@bob.home' }) + assert.deepEqual(granted[0], { bot_id: 'm1', friend_bot_id: 'x1', friend_owner_id: 'o-bob', friend_home: '@bob.home' }) } { const server = mockServer() const posts = [] const baseFetch = server.fetch server.fetch = async (url, init) => { - if (/\/v1\/bots\/[^/]+\/grants\/to-friend/.test(String(url)) && init.method === 'POST') { + if (/\/v1\/bots\/[^/]+\/grants$/.test(String(url)) && init.method === 'POST') { posts.push({ url, body: init.body ? JSON.parse(init.body) : {} }) return new Response(JSON.stringify({ grant_id: 'gr_rest', status: 'active' }), { status: 201 }) } return baseFetch(url, init) } const api = createSocialApi({ server: 'https://s', token: 'owner-token', fetch: server.fetch }) - const result = await api.grantBotToFriend({ botId: 'm1', friendHome: '@bob.home' }) + const result = await api.grantBotToFriend({ botId: 'm1', friendHome: '@bob.home', friendBotId: 'x1' }) assert.equal(result.grant_id, 'gr_rest') assert.equal(posts.length, 1) - assert.match(posts[0].url, /\/v1\/bots\/m1\/grants\/to-friend/) - assert.deepEqual(posts[0].body, { friend_home: '@bob.home' }) + assert.match(posts[0].url, /\/v1\/bots\/m1\/grants$/) + assert.deepEqual(posts[0].body, { friend_bot_id: 'x1' }, 'the real route takes the Bot id, not the home') } { const server = mockServer() @@ -8332,11 +8332,12 @@ test('#500: social/share-friends lists friends without this Bot; share-with-frie t.after(stop) const server = mockServer() const grants = [] + // The real list_friends peer carries bot_id and never an owner_id (#592). server.tools.list_friends = () => ({ bot: { handle: '@danyang.home' }, friends: [ - { relationship_id: 'r-bob', status: 'active', peer: { handle: '@bob.home', display_name: 'BobHome', owner_display_name: 'Bob', owner_id: 'o-bob' } }, - { relationship_id: 'r-eve', status: 'active', peer: { handle: '@eve.home', display_name: 'EveHome', owner_display_name: 'Eve', owner_id: 'o-eve' } }, + { relationship_id: 'r-bob', status: 'active', peer: { bot_id: 'pb-bob', handle: '@bob.home', display_name: 'BobHome', owner_display_name: 'Bob' } }, + { relationship_id: 'r-eve', status: 'active', peer: { bot_id: 'pb-eve', handle: '@eve.home', display_name: 'EveHome', owner_display_name: 'Eve' } }, ], pending_incoming: [], pending_outgoing: [], @@ -8348,10 +8349,16 @@ test('#500: social/share-friends lists friends without this Bot; share-with-frie relationship_id: g.rid, reply_only: false, side: 'inviter', })), }) + // Like the server since #592: the friend is named by friend_bot_id (or the older friend_owner_id); + // a call with neither is refused the way the real tool refuses it. server.tools.grant_bot_to_friend = (a) => { + if (!a.friend_bot_id && !a.friend_owner_id) { + throw Object.assign(new Error('MCP error -32602: Input validation error: Invalid arguments for tool grant_bot_to_friend: Invalid input: expected string, received undefined at friend_owner_id'), { code: 'mcp' }) + } const id = `gr_${grants.length + 1}` - grants.push({ id, for_bot: a.bot_id, grantee: a.friend_home, name: a.friend_home === '@bob.home' ? 'Bob' : 'Eve', rid: 'r-new' }) - return { grant_id: id, status: 'active', friend: { handle: a.friend_home, owner_display_name: a.friend_home === '@bob.home' ? 'Bob' : 'Eve' } } + const who = a.friend_bot_id === 'pb-bob' || a.friend_owner_id === 'o-bob' ? 'Bob' : 'Eve' + grants.push({ id, for_bot: a.bot_id, grantee: who === 'Bob' ? '@bob.home' : '@eve.home', name: who, rid: 'r-new' }) + return { grant_id: id, status: 'active', friend: { handle: who === 'Bob' ? '@bob.home' : '@eve.home', owner_display_name: who } } } const social = await startSocial(ctx.flybot, { server: 'https://social.test', credentialsPath: join(home, 'flybot-social.json'), @@ -8368,17 +8375,18 @@ test('#500: social/share-friends lists friends without this Bot; share-with-frie const emptyGrant = (await call('flybot/social/share-friends', { botId: chief.id })).value assert.equal(emptyGrant.botId, chief.id) assert.deepEqual(emptyGrant.friends.map(f => f.home).sort(), ['@bob.home', '@eve.home']) - assert.equal(emptyGrant.friends.find(f => f.home === '@bob.home').ownerId, 'o-bob') + assert.equal(emptyGrant.friends.find(f => f.home === '@bob.home').peerBotId, 'pb-bob') + assert.equal(emptyGrant.friends.find(f => f.home === '@bob.home').ownerId, undefined, 'a friend never carries an owner id') // Bob already has this Bot → omitted from the share list. grants.push({ id: 'gr_existing', for_bot: member.bot_id, grantee: '@bob.home', name: 'Bob', rid: 'r-bob' }) const filtered = (await call('flybot/social/share-friends', { botId: chief.id })).value assert.deepEqual(filtered.friends.map(f => f.home), ['@eve.home']) - const shared = (await call('flybot/social/share-with-friend', { botId: chief.id, home: '@eve.home', ownerId: 'o-eve' })).value + const shared = (await call('flybot/social/share-with-friend', { botId: chief.id, home: '@eve.home', peerBotId: 'pb-eve' })).value assert.equal(shared.status, 'active') assert.match(shared.message, /Shared .* with Eve/) - assert.ok(server.calls.some(c => c.name === 'grant_bot_to_friend' && c.args.bot_id === member.bot_id && c.args.friend_home === '@eve.home' && c.args.friend_owner_id === 'o-eve')) + assert.ok(server.calls.some(c => c.name === 'grant_bot_to_friend' && c.args.bot_id === member.bot_id && c.args.friend_bot_id === 'pb-eve' && c.args.friend_owner_id === undefined)) assert.ok(shared.access.grants.some(g => g.home === '@eve.home' || g.grantee === '@eve.home' || /Eve/i.test(g.name || ''))) const after = (await call('flybot/social/share-friends', { botId: chief.id })).value @@ -8430,7 +8438,7 @@ test('#563: production "Tool not found" (-32602) is unknown-tool and degr const api = createSocialApi({ server: 'https://s', token: 'owner-token', fetch: server.fetch }) const result = await api.grantBotToFriend({ botId: 'm1', friendHome: '@bob.home' }) assert.equal(result.unavailable, true) - assert.equal(server.calls.some(c => c.name === 'REST POST /v1/bots/grants/to-friend'), true, 'the REST fallback runs before giving up') + assert.equal(server.calls.some(c => c.name === 'REST POST /v1/bots/grants'), true, 'the REST fallback runs before giving up') } { // The handler: the user sees the actionable message, never the raw MCP string. @@ -8467,9 +8475,35 @@ test('#563: production "Tool not found" (-32602) is unknown-tool and degr } }) +test('#592: a share refused as invalid input reads as a plain line, never the raw JSON-RPC text', async (t) => { + const { ctx, home, call, stop } = await team() + t.after(stop) + const server = mockServer() + server.tools.list_friends = () => ({ + friends: [{ relationship_id: 'r1', status: 'active', peer: { bot_id: 'pb-bob', handle: '@bob.home', owner_display_name: 'Bob' } }], + pending_incoming: [], pending_outgoing: [], + }) + server.tools.grant_bot_to_friend = () => { + throw Object.assign(new Error('MCP error -32602: Input validation error: Invalid arguments for tool grant_bot_to_friend: Invalid input: expected string, received undefined at friend_owner_id'), { code: 'mcp' }) + } + 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 result = await call('flybot/social/share-with-friend', { botId: chief.id, home: '@bob.home', peerBotId: 'pb-bob' }) + assert.equal(result.ok, false) + const text = String(result.error?.message || result.error) + assert.match(text, /did not accept that share request|server may need an update/i) + assert.doesNotMatch(text, /-32602|MCP error|expected string|friend_owner_id|invalid input/i) +}) + test('#500: Share with a friend lists candidates and shares on click', async () => { const calls = [] - const friends = [{ home: '@bob.home', name: 'Bob', ownerId: 'o-bob' }] + const friends = [{ home: '@bob.home', name: 'Bob', peerBotId: 'pb-bob' }] // Seed friends list directly (clientPlugin skips useEffect). const { plugin: p2, all: all2, text: text2 } = clientPlugin([friends, '', null], { call: async (endpoint, payload) => { @@ -8490,5 +8524,5 @@ test('#500: Share with a friend lists candidates and shares on click', async () assert.equal(btn.props['aria-label'], 'Share Writer with Bob') await btn.props.onClick() await new Promise(r => setTimeout(r, 0)) - assert.ok(calls.some(([ep, payload]) => ep === 'social/share-with-friend' && payload.botId === 'b' && payload.home === '@bob.home' && payload.ownerId === 'o-bob')) + assert.ok(calls.some(([ep, payload]) => ep === 'social/share-with-friend' && payload.botId === 'b' && payload.home === '@bob.home' && payload.friendBotId === 'pb-bob')) })