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/981-share-list-per-bot.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Share with a friend** filters per Bot, not per person: a friend who runs two Bots used to disappear from the list entirely once either Bot had yours, and the panel then said they already had it — true for that Bot and false for the other one. The other Bot is offered again, under its own name, and the click grants that Bot ([#981](https://github.com/ThinkFlowLab/FlyBot/issues/981)).
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). **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 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); **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

Expand Down
21 changes: 18 additions & 3 deletions plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -2605,20 +2605,35 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
const listed = typeof api.listGrants === 'function'
? await api.listGrants({ botId: member.bot_id }).catch(error => { warn('grants not listed for share', error); return [] })
: []
const grantedHomes = new Set()
// #981: "do they already have this Bot?" is a per-Bot question. Keyed by the person's home, one
// granted Bot hid every Bot of that friend and the panel then said they already had it. A grant that
// names no Bot (`grantPersonView().member` empty) keeps the old per-person answer for that home,
// because nothing here can tell which of their Bots it was.
const grantsByHome = new Map()
for (const grant of listed) {
if (isTalkThroughGrant(member, grant)) continue
const view = grantPersonView(grant)
const key = norm(view.home || (typeof grant.grantee === 'string' ? personHomeOf(grant.grantee) : ''))
if (key) grantedHomes.add(key)
if (!key) continue
const entry = grantsByHome.get(key) ?? { bots: new Set(), person: false }
// `memberHandleOf` reads a bare home back as a handle, so a person-level grant arrives as
// `member === home` — comparing it to the key is what tells the two apart.
if (view.member && norm(view.member) !== key) entry.bots.add(norm(view.member))
else entry.person = true
grantsByHome.set(key, entry)
}
const overview = await friendsOverview()
const friends = []
const seen = new Set()
for (const item of overview.friends ?? []) {
const homeKey = personHomeOf(item.handle) || norm(item.handle)
if (!homeKey || homeKey === '@' || grantedHomes.has(homeKey)) continue
if (!homeKey || homeKey === '@') continue
if (home && homeKey === norm(home.handle)) continue
// Filter the Bot that has the grant, not the friend (#981). A row without a handle cannot be told
// apart from the granted Bot, so it stays hidden rather than offered a second time.
const grantedFor = grantsByHome.get(homeKey)
const handleKey = item.handle ? norm(item.handle) : ''
if (grantedFor && (grantedFor.person || !handleKey || grantedFor.bots.has(handleKey))) continue
// One row per Bot, not per person (#973): a friend may run several Bots, and the row decides which one
// the click shares with — so the key is the Bot, and only a friend who has no Bot id falls back to the
// home. The label names the Bot ("Coder (Bob)"), because "Share with Bob" hid which one got the grant.
Expand Down
81 changes: 81 additions & 0 deletions plugins/social/test/social.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9249,6 +9249,87 @@ test('#973: one friend with two Bots is two rows, each naming its Bot, and the c
assert.match(shared.message, /with Writer \(Bob\)/, 'the receipt names the Bot that got it')
})

// #981: the share list asked "does this person have it?" by home, so once one of a friend's Bots had
// this Bot the other Bots vanished and the panel said they already had it. Keyed by the granted Bot
// instead: only the Bot that has the grant is filtered out of the list.
test('#981: a granted Bot of a two-Bot friend hides that Bot only, not the friend', async (t) => {
const { ctx, home, call, stop } = await team()
t.after(stop)
const server = mockServer()
const state = { granted: ['@coder.bob.home'], sent: [] }
server.tools.list_friends = () => ({
bot: { handle: '@danyang.home' },
friends: [
{ relationship_id: 'r-coder', status: 'active', peer: { bot_id: 'pb-coder', handle: '@coder.bob.home', display_name: 'Coder', owner_display_name: 'Bob' } },
{ relationship_id: 'r-writer', status: 'active', peer: { bot_id: 'pb-writer', handle: '@writer.bob.home', display_name: 'Writer', owner_display_name: 'Bob' } },
],
pending_incoming: [],
pending_outgoing: [],
})
// A grant that names the friend's Bot: `grantee.handle` is that Bot's handle, which is what
// list_bot_grants returns for a Bot-to-Bot grant.
server.tools.list_bot_grants = ({ bot_id }) => ({
bot: { bot_id },
grants: state.granted.map((handle, index) => ({ grant_id: `gr_${index}`, id: `gr_${index}`, grantee: { handle, owner_display_name: 'Bob' }, relationship_id: 'r', reply_only: false, side: 'inviter' })),
})
server.tools.grant_bot_to_friend = (a) => {
state.sent.push(a)
return { grant_id: 'gr_new', status: 'active', friend: { handle: '@writer.bob.home', display_name: 'Writer', owner_display_name: 'Bob' } }
}
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
assert.deepEqual(listing.friends.map(f => f.peerBotId), ['pb-writer'], 'the Bot without the grant is still offered')
assert.deepEqual(listing.friends.map(f => f.name), ['Writer (Bob)'], 'and the row names that Bot')
const shared = (await call('flybot/social/share-with-friend', { botId: chief.id, home: '@bob.home', peerBotId: 'pb-writer' })).value
assert.equal(state.sent.at(-1)?.friend_bot_id, 'pb-writer', 'the click grants the Bot the row names')
assert.match(shared.message, /Writer \(Bob\)/)
state.granted.push('@writer.bob.home')
const none = (await call('flybot/social/share-friends', { botId: chief.id })).value
assert.deepEqual(none.friends, [], 'every Bot of that friend has it: no row, and the panel sentence is then true')
})

// #981: a grant that names only the friend's home names the person, not one of their Bots. `memberHandleOf`
// reads a bare home back as if it were a handle, so the filter must compare it to the home instead of
// trusting it: otherwise a person-level grant offers both of that friend's Bots again, and the click's
// receipt claims a share the server answers with "already granted".
test('#981: a home-level grant hides the whole friend, not one Bot of theirs', 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-coder', status: 'active', peer: { bot_id: 'pb-coder', handle: '@coder.bob.home', display_name: 'Coder', owner_display_name: 'Bob' } },
{ relationship_id: 'r-writer', status: 'active', peer: { bot_id: 'pb-writer', handle: '@writer.bob.home', display_name: 'Writer', owner_display_name: 'Bob' } },
],
pending_incoming: [],
pending_outgoing: [],
})
// Shared with the person, not with one of their Bots — the shape an older client and the plain REST
// `home` route leave behind.
server.tools.list_bot_grants = ({ bot_id }) => ({
bot: { bot_id },
grants: [{ grant_id: 'gr_home', id: 'gr_home', grantee: { handle: '@bob.home', owner_display_name: 'Bob' }, relationship_id: 'r', reply_only: false, side: 'inviter' }],
})
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
assert.deepEqual(listing.friends, [], 'the grant names the person: none of their Bots is offered again')
})

test('#500: share-with-friend surfaces a clear error when Bot Social lacks grant_bot_to_friend', async (t) => {
const { ctx, home, call, stop } = await team()
t.after(stop)
Expand Down
Loading