From 2bae5cdf1935a11684b26101f459ffe1446aab85 Mon Sep 17 00:00:00 2001 From: Birfy Date: Tue, 20 Oct 2026 13:59:00 +0800 Subject: [PATCH] Social: use Bot Social's reply_only to keep live shares in People with access (Fixes #495) normalizeGrant keeps reply_only (as replyOnly) and side; isTalkThroughGrant uses replyOnly as the only rule when the server sends it (joinedRelationships / roster guess only for older servers); a server-confirmed share is never kept apart as a possible talk-through grant, so #383's one row per person holds. --- changelog.d/495-share-reply-only.fixed.md | 1 + docs/subsystems/social.md | 2 +- plugins/social/src/mcp-client.js | 4 + plugins/social/src/social.js | 10 +- plugins/social/test/social.test.js | 152 ++++++++++++++++++++++ 5 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 changelog.d/495-share-reply-only.fixed.md diff --git a/changelog.d/495-share-reply-only.fixed.md b/changelog.d/495-share-reply-only.fixed.md new file mode 100644 index 000000000..99fbcbd24 --- /dev/null +++ b/changelog.d/495-share-reply-only.fixed.md @@ -0,0 +1 @@ +**People with access** keeps listing a friend after the two of you joined each other's invite, so the owner can still see and **Revoke** that share on both homes; FlyBot now uses Bot Social's `reply_only` mark to tell a real share from a talk-through grant ([#495](https://github.com/ThinkFlowLab/FlyBot/issues/495)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 2e838dda1..a2fd2b305 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -124,7 +124,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - **#136:** A taken `displayName` at onboard retries `Name2` … rather than refusing to start. Your handle is shown in Add friend (#181). - **#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, on startup if not yet synced, and again for every new identity of that Bot (the synced record names the identity: a room-only identity is released after its last face room and the next room registers a new one, #182). `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. What is sent is the look the owner's home draws (`shownLook` in `look.js`): a Bot with no stored shape or color gets the id-picked default of core `characters.js` (`defaultShapeOf` / `defaultColorOf`, kept equal to core by a test), so the other home shows that look and not a handle-seeded one; a stored image or emoji is sent as is, image before emoji before shape as `BotMark` draws them. A new identity gets its look before `registerOnce` returns, so a face-room join never shows peers a seeded look first (#182). In the waiting dialog my own Bot is found by its room record (`remote: false`, local id), the Bot list handle, or the `joinedAs` of this join (`mineBots`), and always uses the roster look (#182). 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)`; a label `grantPersonView` already made keeps its display name when `syncOutgoingGrants` reads the normalized grant again (#275), and only a bare handle falls back to the lowercase 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`: grantee in `inboundBots`, and either a relationship this home joined or, for joins from before that record, the home's first relationship with the Bot in `via`). `social/join-invite` records the joined relationship (`settings.joinedRelationships[rid]`), because Bot Social lists a friendship's relationships (`relationship_id` = the first, `relationship_ids` = all) without saying who joined, and both sides of a relationship hold a grant naming the other's Bot: matching any of `relationship_ids` would hide a real share when a Bot shared with us later joins our invite (#383: an already-shared Chief that joined the friend's Helper invite listed the friend twice; a join made before the record, in a second relationship, still shows twice until it is joined again). `social/access` returns **one row per person** (`accessRows`, keyed by the person's home): a row carries `grantIds`, and `social/revoke-grant` with `grantIds` revokes each of them, so two shares of one Bot to one person are one row and one Revoke. A grant to a Bot shared with us in a relationship not recorded as joined (possibly the talk-through side of an older join) keeps a row of its own and is never in another row's `grantIds`, so the person's Revoke cannot break that chat. Every revoke runs in a per-Bot fence: a `syncOutgoingGrants` whose `list_bot_grants` overlapped it does not store that list and resolves `false` (fail closed), and the roster refreshes once after the fence. The joined relationship and the offered Bot are recorded right after `join_invite`, before the owner-approval follow-up. Join copy names the person (`owner_display_name`), not the shared Bot. `social/join-invite` marks the offered Bot inbound **before** its first roster refresh (#228): the grant sync that refresh starts then already sees the talk-through grant as talk-through, so no core grant on the joiner's Bot names the inviter's Bot and the re-filter does not drop the shared Bot until a later refresh (it shows with the success toast). Bot Social's real `join_invite` answer has no `bot` field: `inviter` is the invited Bot itself (a member handle such as `@chief.bob.home`; `joined.bot`, `inviter.bot` and `offered_bot` are the older shapes, with `inviter` at the home). A member-level `inviter` counts as the offered Bot, so it is marked inbound before the refresh and the toast names it (#342; without it #303's awaited sync stored the server's talk-through grant on the joiner's Bot as the joiner sharing back, and the re-filter hid the inviter's Bot and listed the inviter in the joiner's People with access). A talk-through grant is never sharing: the joiner's Bots stay default-deny after a join. A Bot that appears on a roster this home already fetched and is not in `inboundBots` waits for the grant sync before the roster is published (#303; "already fetched" is the `rosterFetched` flag, so an empty roster of a home with no friends yet counts and the first friend's Bot waits too; the very first fetch does not wait, #92, as `registerAll` synced grants at start): the Bot that just joined our invite would otherwise be published for a moment, get a chat record on this home and be marked gone seconds later (the next load opened that dead chat). The awaited sync classifies talk-through grants against the fresh roster (`syncOutgoingGrants({ roster })`), not the previous `remote`, so a refresh that also brings another new Bot cannot store a talk-through grant as a core grant. `syncOutgoingGrants` resolves `false` when any Bot's grants could not be listed; the publish then fails closed: the unseen Bots are withheld from this publish and from the background re-filter (`withheldRemotes`), stay unseen for the next fetch (which syncs again), and appear only after a sync succeeds. Bots already seen and Bots shared with us never wait, so the usual refresh still re-filters in the background. A late grant re-filter of an older roster fetch never overwrites a newer one (fetch counter, not a timestamp). Fetches are numbered when they ask, not when they land (#224): a `list_remote_bots` answer that lands after a newer one (the background refresh `hide-remote` starts, a slow link) is dropped, so it cannot hide a re-shared Bot again; the grant re-filter applies the same answer again and does not count as a second absent fetch toward forgetting a local hide. An open **People with access** re-reads `social/access` every 5 s while visible, and `social/access` syncs only that Bot's grants (`syncOutgoingGrants({ only })`). Bot Social itself still records the talk-through side of the relationship as a grant on the joiner's Bot (`list_bot_grants`); FlyBot filters it on both sides (#161, #178). 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)`; a label `grantPersonView` already made keeps its display name when `syncOutgoingGrants` reads the normalized grant again (#275), and only a bare handle falls back to the lowercase 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`). Bot Social's `list_bot_grants` marks that grant `reply_only` (bot-social #23), which `normalizeGrant` keeps as `replyOnly` (with `side`); when present it is the only rule (#495): `replyOnly: true` is talk-through and `replyOnly: false` is always a share, so a share in a relationship both homes recorded as joined (two friends joining each other's invite, which Bot Social regrants into the first relationship) stays listed and revocable on both homes, and a confirmed share is never kept apart as a possible talk-through grant in `accessRows`. Only a server that does not send the field falls back to the guess: grantee in `inboundBots`, and either a relationship this home joined or, for joins from before that record, the home's first relationship with the Bot in `via`. `social/join-invite` records the joined relationship (`settings.joinedRelationships[rid]`), because Bot Social lists a friendship's relationships (`relationship_id` = the first, `relationship_ids` = all) without saying who joined, and both sides of a relationship hold a grant naming the other's Bot: matching any of `relationship_ids` would hide a real share when a Bot shared with us later joins our invite (#383: an already-shared Chief that joined the friend's Helper invite listed the friend twice; a join made before the record, in a second relationship, still shows twice until it is joined again). `social/access` returns **one row per person** (`accessRows`, keyed by the person's home): a row carries `grantIds`, and `social/revoke-grant` with `grantIds` revokes each of them, so two shares of one Bot to one person are one row and one Revoke. A grant to a Bot shared with us in a relationship not recorded as joined (possibly the talk-through side of an older join) keeps a row of its own and is never in another row's `grantIds`, so the person's Revoke cannot break that chat. Every revoke runs in a per-Bot fence: a `syncOutgoingGrants` whose `list_bot_grants` overlapped it does not store that list and resolves `false` (fail closed), and the roster refreshes once after the fence. The joined relationship and the offered Bot are recorded right after `join_invite`, before the owner-approval follow-up. Join copy names the person (`owner_display_name`), not the shared Bot. `social/join-invite` marks the offered Bot inbound **before** its first roster refresh (#228): the grant sync that refresh starts then already sees the talk-through grant as talk-through, so no core grant on the joiner's Bot names the inviter's Bot and the re-filter does not drop the shared Bot until a later refresh (it shows with the success toast). Bot Social's real `join_invite` answer has no `bot` field: `inviter` is the invited Bot itself (a member handle such as `@chief.bob.home`; `joined.bot`, `inviter.bot` and `offered_bot` are the older shapes, with `inviter` at the home). A member-level `inviter` counts as the offered Bot, so it is marked inbound before the refresh and the toast names it (#342; without it #303's awaited sync stored the server's talk-through grant on the joiner's Bot as the joiner sharing back, and the re-filter hid the inviter's Bot and listed the inviter in the joiner's People with access). A talk-through grant is never sharing: the joiner's Bots stay default-deny after a join. A Bot that appears on a roster this home already fetched and is not in `inboundBots` waits for the grant sync before the roster is published (#303; "already fetched" is the `rosterFetched` flag, so an empty roster of a home with no friends yet counts and the first friend's Bot waits too; the very first fetch does not wait, #92, as `registerAll` synced grants at start): the Bot that just joined our invite would otherwise be published for a moment, get a chat record on this home and be marked gone seconds later (the next load opened that dead chat). The awaited sync classifies talk-through grants against the fresh roster (`syncOutgoingGrants({ roster })`), not the previous `remote`, so a refresh that also brings another new Bot cannot store a talk-through grant as a core grant. `syncOutgoingGrants` resolves `false` when any Bot's grants could not be listed; the publish then fails closed: the unseen Bots are withheld from this publish and from the background re-filter (`withheldRemotes`), stay unseen for the next fetch (which syncs again), and appear only after a sync succeeds. Bots already seen and Bots shared with us never wait, so the usual refresh still re-filters in the background. A late grant re-filter of an older roster fetch never overwrites a newer one (fetch counter, not a timestamp). Fetches are numbered when they ask, not when they land (#224): a `list_remote_bots` answer that lands after a newer one (the background refresh `hide-remote` starts, a slow link) is dropped, so it cannot hide a re-shared Bot again; the grant re-filter applies the same answer again and does not count as a second absent fetch toward forgetting a local hide. An open **People with access** re-reads `social/access` every 5 s while visible, and `social/access` syncs only that Bot's grants (`syncOutgoingGrants({ only })`). Bot Social itself still records the talk-through side of the relationship as a grant on the joiner's Bot (`list_bot_grants`); FlyBot filters it on both sides (#161, #178). 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, inline in the friend Bot's settings (`Remove Helper? Bob can share it again.` · **Remove** · **Keep**, #230; never the browser `confirm()`), and the friend's **Auto-send** checkbox says what it covers (`Auto-send my Bots' replies to Bob` with a one-line hint, #417) 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/mcp-client.js b/plugins/social/src/mcp-client.js index 1b3e01319..93d4b4e35 100644 --- a/plugins/social/src/mcp-client.js +++ b/plugins/social/src/mcp-client.js @@ -262,6 +262,10 @@ export function normalizeGrant(item) { ...(view.member ? { member: view.member } : {}), ...(item.relationship_id || item.relationshipId ? { relationshipId: String(item.relationship_id ?? item.relationshipId) } : {}), ...(item.room_id || item.roomId ? { roomId: String(item.room_id ?? item.roomId) } : {}), + // Bot Social says which grants only let the grantee answer (the join's talk-through grant, bot-social + // #23) and which side of the relationship we are; FlyBot no longer has to guess (#495). + ...(typeof (item.reply_only ?? item.replyOnly) === 'boolean' ? { replyOnly: item.reply_only ?? item.replyOnly } : {}), + ...(item.side === 'joiner' || item.side === 'inviter' ? { side: item.side } : {}), } } diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 38977766e..6b458c09a 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -881,6 +881,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // 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, homes = remote.homes) { + // Bot Social marks the talk-through grant `reply_only`; when it says so, that is the only rule, so a + // real share in a relationship both homes joined (a regrant through the second invite) stays listed + // and revocable (#495). The guess below is for servers that do not send the field. + if (typeof grant.replyOnly === 'boolean') return grant.replyOnly const rid = grant.relationshipId const grantee = memberHandleOf(grant.grantee) if (!rid || !grantee || !member?.handle || !settings.inboundBots?.[grantee]) return false @@ -1161,6 +1165,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp else revoking.delete(botId) } } + // Per Bot, the grant ids Bot Social confirmed as full shares (`reply_only: false`) at the last sync (#495). + const confirmedShares = new Map() // `only`: one Bot's grants (an open People with access refreshes just that Bot). `roster`: the // friends' homes to classify talk-through grants against, when they are newer than `remote`. // Resolves true only when every Bot's grants were listed and stored; a failure is warned, not thrown. @@ -1198,6 +1204,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } }).filter(Boolean) await flybot.grants.set(bot.id, [...people, ...roomScoped]) + confirmedShares.set(bot.id, new Set(listed.filter(grant => grant.replyOnly === false && grant.id).map(grant => grant.id))) } catch (error) { ok = false warn(`${bot.name} grants not synced`, error) @@ -2047,7 +2054,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // A grant to a Bot shared with us, in a relationship not recorded as joined here, may be a // talk-through grant from a join made before that record (#383): it keeps a row of its own, so // Revoke on the person's row never takes it along and breaks that chat (Codex P1 on #387). - const toInbound = Boolean(settings.inboundBots?.[memberHandleOf(grant.grantee)]) + // A grant Bot Social confirmed as a full share is never unsure (#495). + const toInbound = Boolean(settings.inboundBots?.[memberHandleOf(grant.grantee)]) && !confirmedShares.get(botId)?.has(grant.id) const unsure = toInbound && !(grant.relationshipId && settings.joinedRelationships?.[grant.relationshipId]) const key = unsure ? `grant:${grant.id}` : (norm(grantee) || String(grant.id)) const rooms = roomsUsing(botId, view.member || grant.grantee) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 2b1dcc62b..939d3aa04 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -5388,6 +5388,158 @@ test('#383: People with access shows one row per person; its Revoke removes each assert.deepEqual((ctx.flybot.grants.of(chief.id) ?? []).map(grant => grant.id), ['g-3']) }) +// #495: both friends joined each other's invite, so Bot Social regranted the second invite into the FIRST +// relationship and both homes recorded it as joined. Bot Social's `reply_only` decides, not that record. +test('#495: after the friend\'s invite is regranted into our relationship, our real share stays listed and revocable (inviter of r1)', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer() + // Step 1: we invited for Chief, Bob's Chief joined (r1). Step 2: Bob invites for his Chief and our Chief + // joins; Bot Social adds Bob's full grants to r1 and answers with the same relationship (regrantViaInvite). + server.tools.join_invite = () => ({ relationship_id: 'r1', status: 'active', regranted: true, inviter: { bot_id: 'x1', handle: '@chief.bob.home', display_name: 'Chief', owner_display_name: 'Bob' }, awaiting: null }) + server.tools.list_bot_grants = () => ({ grants: [{ grant_id: 'g-share', grantee: { handle: '@chief.bob.home', display_name: 'Chief', owner_display_name: 'Bob' }, relationship_id: 'r1', side: 'inviter', reply_only: false }] }) + server.tools.list_remote_bots = () => ({ + homes: [{ + relationship_id: 'r1', + relationship_ids: ['r1'], + via: ['@chief.danyang.home'], + home: { bot_id: 'h2', handle: '@bob.home', display_name: 'Bob', owner_display_name: 'Bob' }, + bots: [{ bot_id: 'x1', handle: '@chief.bob.home', display_name: 'Chief', grant_id: 'g-in', relationship_id: 'r1', reply_only: false }], + }], + }) + const settingsPath = join(home, 'flybot-social-settings.json') + writeFileSync(settingsPath, JSON.stringify({ inboundBackfilled: true, inboundBots: {} })) + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, 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 }) + const chief = ctx.flybot.bot.list().find(bot => bot.name === 'Chief') + assert.equal((await call('flybot/social/join-invite', { code: 'B2', botId: chief.id })).ok, true) + for (let n = 0; n < 30; n++) await new Promise(resolve => setTimeout(resolve, 5)) + const saved = JSON.parse(readFileSync(settingsPath, 'utf8')) + assert.equal(saved.joinedRelationships?.r1, true, 'the regranted join is recorded on r1, as on the live server') + assert.equal(saved.inboundBots?.['@chief.bob.home'], true) + const access = (await call('flybot/social/access', { botId: chief.id })).value + assert.equal(access.granted, true) + assert.deepEqual(access.grants.map(row => row.grantee), ['@bob.home'], 'Bob is listed once') + assert.deepEqual(access.grants[0].grantIds, ['g-share']) + const before = server.calls.length + assert.equal((await call('flybot/social/revoke-grant', { botId: chief.id, grantId: access.grants[0].id, grantIds: access.grants[0].grantIds })).ok, true) + const revoked = server.calls.slice(before).filter(c => /revoke|DELETE/i.test(c.name)).map(c => JSON.stringify(c.args)) + assert.equal(revoked.some(args => args.includes('g-share')), true, 'Revoke removes the share') +}) + +test('#495: the friend whose invite we joined first still lists us after we join theirs (joiner of r1)', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer() + // The other home of the same story: our Chief joined Bob's invite (r1, recorded), and Bob's Chief then + // joined ours, which Bot Social regranted into r1. Our grant to Bob's Chief is now a full share. + server.tools.list_bot_grants = () => ({ grants: [{ grant_id: 'g-regrant', grantee: { handle: '@chief.bob.home', display_name: 'Chief', owner_display_name: 'Bob' }, relationship_id: 'r1', side: 'joiner', reply_only: false }] }) + 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: 'x1', handle: '@chief.bob.home', display_name: 'Chief', grant_id: 'g-in' }], + }], + }) + const settingsPath = join(home, 'flybot-social-settings.json') + writeFileSync(settingsPath, JSON.stringify({ inboundBackfilled: true, inboundBots: { '@chief.bob.home': true }, joinedRelationships: { r1: true } })) + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, 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 }) + const chief = ctx.flybot.bot.list().find(bot => bot.name === 'Chief') + const access = (await call('flybot/social/access', { botId: chief.id })).value + assert.deepEqual(access.grants.map(row => [row.grantee, row.grantIds]), [['@bob.home', ['g-regrant']]], 'Bob is listed once, with Revoke') + assert.equal((ctx.flybot.grants.of(chief.id) ?? []).some(grant => grant.id === 'g-regrant'), true, 'stored as our share') +}) + +test('#495: a reply-only grant alone is never listed as a share, even with no join recorded', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer() + // Our Chief joined Bob's invite on another install (nothing recorded here): Bot Social still says its grant + // only lets Bob's Helper answer. + server.tools.list_bot_grants = () => ({ grants: [{ grant_id: 'g-tt', grantee: '@helper.bob.home', name: 'Bob (@bob.home)', relationship_id: 'r1', side: 'joiner', reply_only: true }] }) + server.tools.list_remote_bots = () => ({ homes: [] }) + const settingsPath = join(home, 'flybot-social-settings.json') + writeFileSync(settingsPath, JSON.stringify({ inboundBackfilled: true, inboundBots: {} })) + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, 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() + const chief = ctx.flybot.bot.list().find(bot => bot.name === 'Chief') + const access = (await call('flybot/social/access', { botId: chief.id })).value + assert.deepEqual(access.grants, [], 'People with access stays empty') + assert.equal(access.granted, false) + assert.equal((ctx.flybot.grants.of(chief.id) ?? []).length, 0, 'the reply-only grant is not stored as ours') +}) + +test('#495: with reply_only, the #383 case keeps one row per person and Revoke leaves the talk-through grant', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer() + // r1: we shared Chief, Bob's Chief joined. r2: our already-shared Chief joined Bob's invite for Helper before + // joins were recorded. r3: Bob's Helper, a Bot shared with us, also joined our invite for Chief. Bot Social + // says which grant is which, so the two real shares are one row and the talk-through grant is no row. + server.tools.list_bot_grants = () => ({ + grants: [ + { grant_id: 'g-share', grantee: '@chief.bob.home', name: 'Bob (@bob.home)', relationship_id: 'r1', side: 'inviter', reply_only: false }, + { grant_id: 'g-tt', grantee: '@helper.bob.home', name: 'Bob (@bob.home)', relationship_id: 'r2', side: 'joiner', reply_only: true }, + { grant_id: 'g-share-2', grantee: '@helper.bob.home', name: 'Bob (@bob.home)', relationship_id: 'r3', side: 'inviter', reply_only: false }, + ], + }) + server.tools.list_remote_bots = () => ({ + homes: [{ + relationship_id: 'r1', + relationship_ids: ['r1', 'r2', 'r3'], + 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-2' }], + }], + }) + const settingsPath = join(home, 'flybot-social-settings.json') + writeFileSync(settingsPath, JSON.stringify({ inboundBackfilled: true, inboundBots: { '@helper.bob.home': true } })) + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, 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 }) + const chief = ctx.flybot.bot.list().find(bot => bot.name === 'Chief') + const access = (await call('flybot/social/access', { botId: chief.id })).value + assert.deepEqual(access.grants.map(row => [row.grantee, row.grantIds]), [['@bob.home', ['g-share', 'g-share-2']]], 'one row for Bob') + assert.equal((ctx.flybot.grants.of(chief.id) ?? []).some(grant => grant.id === 'g-tt'), false, 'the talk-through grant is not stored as ours') + const before = server.calls.length + assert.equal((await call('flybot/social/revoke-grant', { botId: chief.id, grantId: access.grants[0].id, grantIds: access.grants[0].grantIds })).ok, true) + const revoked = server.calls.slice(before).filter(c => /revoke|DELETE/i.test(c.name)).map(c => JSON.stringify(c.args)) + assert.equal(revoked.some(args => args.includes('g-share"')), true) + assert.equal(revoked.some(args => args.includes('g-share-2')), true) + assert.equal(revoked.some(args => args.includes('g-tt')), false, 'the chat with Helper keeps working') +}) + +test('#495: normalizeGrant keeps Bot Social\'s reply_only and side', () => { + assert.deepEqual( + [normalizeGrant({ grant_id: 'g1', grantee: '@chief.bob.home', relationship_id: 'r1', reply_only: true, side: 'joiner' }), normalizeGrant({ grant_id: 'g2', grantee: '@chief.bob.home', reply_only: false, side: 'inviter' }), normalizeGrant({ grant_id: 'g3', grantee: '@chief.bob.home', side: 'other' })].map(grant => [grant.replyOnly, grant.side]), + [[true, 'joiner'], [false, 'inviter'], [undefined, undefined]], + ) +}) + test('#383: the join is recorded before the owner-approval round trip, so a lost approval answer keeps it', async (t) => { const { ctx, call, home, stop } = await team() t.after(stop)