Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/592-share-with-friend-peer-bot.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
2 changes: 1 addition & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions plugins/social/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}.` })
Expand Down
15 changes: 9 additions & 6 deletions plugins/social/src/mcp-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
const lib = parsed.protocol === 'http:' ? http : https
const agent = init.agent ?? agentFor(url)
const method = String(init.method ?? 'GET').toUpperCase()
const headers = { ...(init.headers ?? {}) }

Check warning on line 44 in plugins/social/src/mcp-client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
const signal = init.signal
return new Promise((resolve, reject) => {
const req = lib.request(parsed, { method, headers, agent }, (res) => {
Expand Down Expand Up @@ -76,7 +76,7 @@
const list = [...items]
if (list.length === 0) return []
const cap = Math.max(1, Math.min(limit || GRANT_CONCURRENCY, list.length))
const out = new Array(list.length)

Check warning on line 79 in plugins/social/src/mcp-client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-new-array)

Do not use `new Array(singleArgument)`.
let next = 0
async function worker() {
for (;;) {
Expand Down Expand Up @@ -433,29 +433,32 @@

// 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) {
if (isUnknownTool(error) || isMissingEndpoint(error)) return { unavailable: true }
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
Expand Down
20 changes: 16 additions & 4 deletions plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 } : {}),
})
Expand Down Expand Up @@ -2253,27 +2254,38 @@ 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')
const member = await register(bot, { forInvite: true })
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
Expand Down
Loading
Loading