diff --git a/changelog.d/178-grantee-sees-shared-bot.fixed.md b/changelog.d/178-grantee-sees-shared-bot.fixed.md new file mode 100644 index 000000000..9795ef1bd --- /dev/null +++ b/changelog.d/178-grantee-sees-shared-bot.fixed.md @@ -0,0 +1 @@ +A Bot a friend shared with you shows in your list again: the talk-through grant on your own Bot no longer hides it (regression of the #161 fix), it no longer shows as you sharing that Bot, and the join message names the person ("SelfA shared Helper with you."). ([#178](https://github.com/ThinkFlowLab/FlyBot/issues/178)) diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 17a411486..7270abd8c 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -83,7 +83,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers - **#136:** A taken `displayName` at onboard retries `Name2` … rather than refusing to start. Sidebar **+** → **Copy my handle** copies `@your.home` (toast repeats it). - **#137:** Owner-chosen Bot avatar/color is sent with MCP `update_bot_profile { bot_id?, display_name?, avatar?, color? }` (REST `PATCH /v1/bots/{id}/profile`) whenever a local Bot's look changes and on startup if not yet synced. `avatar` is a preset id, a single emoji, or `data:image/(png|jpeg|webp);base64` ≤ 64KiB; `color` is `#rrggbb`. Remote look is read from `list_remote_bots` / `list_friends` peer / `list_bot_grants` / face-room `members[]` (`avatar`, `avatar_kind`, `color`, `avatar_updated_at`) and shown in the main sidebar list, group member lists, waiting tiles, the picker, and chat headers. Cache key is `avatar_updated_at`; missing look falls back to the handle-seeded palette. Face-room events carry no look — `members[]` is the source. All fields are optional so an older Bot Social is unchanged. -- **#138 / #161 / #164:** Accepting a Helper invite grants Helper only. The joiner picks a talk-through Bot (not shared). Success copy is `{person} shared {Bot} with you.` Manage access lists `SelfB1935 (@selfb1935.home)`. `grantPersonView` / `normalizeGrant` read Bot Social's object-shaped `grantee` (`handle`, `owner_display_name`) and never `String()` it (`[object Object]` → `@objectobject`). Core grants store a canonical `@handle` (never decorated `Chief@…`). Friends' Bots drops reverse-friends of invites this home created (`outgoingPeerHandles` wins over `inboundBots`) and room-only remotes. On upgrade, remotes already on `list_remote_bots` are seeded into `inboundBots` once so reciprocal friends are not dropped — except the Bot that joined our invite and face-room-only remotes. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only. +- **#138 / #161 / #164:** Accepting a Helper invite grants Helper only. The joiner picks a talk-through Bot (not shared). Success copy is `{person} shared {Bot} with you.` Manage access lists `SelfB1935 (@selfb1935.home)`. `grantPersonView` / `normalizeGrant` read Bot Social's object-shaped `grantee` (`handle`, `owner_display_name`) and never `String()` it (`[object Object]` → `@objectobject`). Core grants store a canonical `@handle` (never decorated `Chief@…`). Friends' Bots drops reverse-friends of invites this home created (`outgoingPeerHandles`) and room-only remotes, but a handle the joiner explicitly received (`inboundBots[handle]`) always shows (#178): the talk-through grant on the joiner's own Bot names the shared Bot as grantee and must not hide it, nor appear in that Bot's People with access (`isTalkThroughGrant`: same relationship, Bot in `via`, grantee in `inboundBots`). Join copy names the person (`owner_display_name`), not the shared Bot. On upgrade, remotes already on `list_remote_bots` are seeded into `inboundBots` once so reciprocal friends are not dropped — except the Bot that joined our invite and face-room-only remotes. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only. - **#139:** **Remove this Bot** asks once (`Remove Helper? Bob can share it again.`) and calls `decline_bot_grant { grant_id, bot_id }` (`DELETE /v1/remote-bots/{grantId}`) so the owner's Manage access drops it. `grant_not_found` is treated as already removed. An older server (404 on the tool/endpoint) falls back to the local hide. Re-joining after a decline may return `regranted: true` on the existing friendship; live access still answers `409 relationship_exists`; a spent invite is `410 invite_unavailable`. Copy stays English (“You're already friends…”, “This invite was already used.”). Re-joining an existing friendship unhides only that invite's Bot/peer home. - **#140:** Auto-send defaults off. Accepting a friend request uses that relationship's peer home for Auto-send, not the first roster entry. The owner sees Approve / Reject in the chat, not “Waiting for the owner's approval.” Composer drafts are labelled You. - **#136 (suffix):** Onboard retries truncate the sanitized handle body so `Name2` / `Name3` still change within the 32-character `homeHandleOf` limit. diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 499e32186..4e282b54d 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -684,9 +684,12 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const key = norm(bot.handle) if (!key) return false if (declinedRemotes.has(key)) return false - // Outgoing grants win: the Bot that joined our invite is not granted back (#138 / #161). + // A Bot someone shared with us (we joined their invite) always shows, even though the + // talk-through grant on our own Bot names it as grantee (#178). + if (settings.inboundBots?.[key]) return true + // Otherwise outgoing grants win: the Bot that joined our invite is not granted back (#138 / #161). if (outgoingPeerHandles().has(key)) return false - if (settings.inboundBots?.[key] || settings.inboundBots?.[bot.home]) return true + if (settings.inboundBots?.[bot.home]) return true if (settings.roomBots?.[key]) return false return true } @@ -718,9 +721,19 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // The friendship covers only these local members (an invite made for one Bot, or joined // as one Bot); empty when it covers this whole home. Only they can talk to this friend. via: (entry.via ?? []).map(norm), + ...(entry.relationship_id ? { relationshipId: String(entry.relationship_id) } : {}), } }) } + // The grant a joiner's own Bot carries for talking through to a shared Bot: same + // relationship, the Bot is one this friendship covers, and the grantee is a Bot the friend + // shared with us. It is not us sharing that Bot (#178). + function isTalkThroughGrant(member, grant) { + const rid = grant.relationshipId + const grantee = memberHandleOf(grant.grantee) + if (!rid || !grantee || !member?.handle || !settings.inboundBots?.[grantee]) return false + return remote.homes.some(entry => entry.relationshipId === rid && entry.via.includes(norm(member.handle))) + } function listedRemoteBots() { return remote.homes.flatMap(entry => entry.bots.map(bot => ({ handle: bot.handle, name: bot.name, home: entry.home, homeName: entry.name, owner: entry.owner, @@ -938,7 +951,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp try { const listed = await api.listGrants({ botId: member.bot_id }) const roomScoped = (flybot.grants.of?.(bot.id) ?? []).filter(grant => grant.roomId) - const people = listed.filter(grant => !grant.roomId).map(grant => { + const people = listed.filter(grant => !grant.roomId && !isTalkThroughGrant(member, grant)).map(grant => { const view = grantPersonView(grant) // Core normGrantee cannot match decorated non-@ values; persist a canonical @handle. const canonical = view.member || view.home || memberHandleOf(grant.grantee) @@ -1446,7 +1459,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } if (offered?.handle) rememberInbound(offered.handle) const inviterHome = joined.inviter?.handle ? homeOfPeer(joined.inviter.handle) : added[0]?.home - const inviterName = cleanText(joined.inviter?.display_name || joined.inviter?.owner_display_name || added[0]?.owner || added[0]?.homeName) + // Name the person, not the Bot they shared (#178: "SelfA1933 shared Helper with you"). + const inviterName = cleanText(joined.inviter?.owner_display_name || added[0]?.owner || added[0]?.homeName || joined.inviter?.display_name) const inviterBotName = cleanText(offered?.display_name || offered?.name || (added.length === 1 ? added[0].name : '')) // A new friendship starts with Auto-send off; only push server auto-approve if the owner already opted in. if (joined.relationship_id && autoAcceptOf(inviterHome)) await serverAutoApprove(joined.relationship_id, true) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 46d0458d2..1c5eab9a6 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -3908,6 +3908,40 @@ test('#161: invite accept does not list the joiner Chief on a fresh install', as assert.equal(saved.inboundBots?.['@chief.bob.home'], undefined) }) +test('#178: the Bot a friend shared shows for the grantee although our Bot carries the talk-through grant', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer() + // Joiner side of one invite: bob shared Helper; our Chief carries the talk-through grant to it. + server.tools.list_bot_grants = () => ({ + grants: [{ id: 'g-tt', grantee: '@helper.bob.home', name: 'bob (@bob.home)', relationship_id: 'r1' }], + }) + server.tools.list_remote_bots = () => ({ + homes: [{ + relationship_id: 'r1', + via: ['@chief.danyang.home'], + home: { bot_id: 'h2', handle: '@bob.home', display_name: 'Bob', owner_display_name: 'Bob' }, + bots: [{ bot_id: 'x2', handle: '@helper.bob.home', display_name: 'Helper', grant_id: 'g-in' }], + }], + }) + const settingsPath = join(home, 'flybot-social-settings.json') + writeFileSync(settingsPath, JSON.stringify({ inboundBots: { '@helper.bob.home': true } })) + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + await social.remoteBots({ fresh: true }) + await new Promise(resolve => setTimeout(resolve, 50)) + const listed = (await call('flybot/social/remote-bots', { fresh: true })).value + assert.equal(listed.homes[0]?.bots.some(bot => bot.handle === '@helper.bob.home'), true, 'the shared Bot shows for the person it was shared with') + // The talk-through grant is not us sharing Chief. + const chief = ctx.flybot.bot.list().find(bot => bot.name === 'Chief') + assert.equal((ctx.flybot.grants.of(chief.id) ?? []).some(grant => grant.grantee === '@helper.bob.home'), false) +}) + test('#140: respond Auto-send follows the accepted relationship, not the first roster home', async (t) => { const { ctx, call, stop } = await team() t.after(stop)