diff --git a/changelog.d/1000-accept-reinvite-both-directions.fixed.md b/changelog.d/1000-accept-reinvite-both-directions.fixed.md new file mode 100644 index 00000000..dd2c69bc --- /dev/null +++ b/changelog.d/1000-accept-reinvite-both-directions.fixed.md @@ -0,0 +1 @@ +Accepting a friend's invite again now shares your Bot back with them ([#1000](https://github.com/ThinkFlowLab/FlyBot/issues/1000)): the two of you can message each other right away instead of one direction staying silently absent until they share again. diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 2242f093..15551ba7 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -133,7 +133,7 @@ Creating a group opens its Session with zero turns, and the host draws no sessio - **#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`). 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 Bot** (#995): `accessRows` keys a grant that names a Bot (its `member` differs from the person's home) by that Bot, labels it from the name Bot Social sent for it in `list_bot_grants` (`normalizeGrant` keeps `botName` / `ownerName`; a server that sends no name falls back to `Owner (@bot.home)`, never two identical person rows), and the row's Revoke takes exactly that grant — so one friend running two Bots is two rows and taking one back leaves the other. A grant that names no Bot keeps the per-person row it had, and a row still carries `grantIds`, which `social/revoke-grant` revokes one after another: grants of one Bot to one person (two invites) are that Bot's row, and #387's own-row talk-through grant is never in another row's `grantIds`. 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) from your list? Your own Bots stay shared. Bob can share it again.` · **Remove** · **Keep**, #230, #496; 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 already have Helper (Bob).”, “This invite was already used.”). Re-joining an existing friendship unhides only that invite's Bot/peer home. -- **#496:** re-accepting an invite for a Bot this home already has is a result, not an error. On `409 relationship_exists` `social/join-invite` answers `{ alreadyHave: true, status: 'active', bot, botName, ownerName, restored: [{ handle, name, owner }] }`. Bot Social's 409 names no Bot, so the plugin reads the public invite info (`GET /v1/invites/{code}`, `inviteInfo` in `mcp-client.js`, sent without a token) for the invite's Bot and owner. `restored` lists only Bots that were locally hidden or declined and are listed again after the unhide. The panel shows neutral text `You already have Chief (SelfA1233).` (Bot first, owner in parentheses, #384), or `Chief (SelfA1233) is back in your list.` when something really came back; never red. Without a home (legacy) the join still throws, with the neutral `You already have this friend's Bot.`. +- **#496:** re-accepting an invite for a Bot this home already has is a result, not an error. On `409 relationship_exists` `social/join-invite` answers `{ alreadyHave: true, status: 'active', bot, botName, ownerName, restored: [{ handle, name, owner }] }`. Bot Social's 409 names no Bot, so the plugin reads the public invite info (`GET /v1/invites/{code}`, `inviteInfo` in `mcp-client.js`, sent without a token) for the invite's Bot and owner. `restored` lists only Bots that were locally hidden or declined and are listed again after the unhide. The panel shows neutral text `You already have Chief (SelfA1233).` (Bot first, owner in parentheses, #384), or `Chief (SelfA1233) is back in your list.` when something really came back; never red. Without a home (legacy) the join still throws, with the neutral `You already have this friend's Bot.`. **#1000:** that path — and a `/join_invite` that answers `regranted: true` on a relationship that already exists — also grants the joining Bot back to the inviter's Bot (`grant_bot_to_friend`, `chat.public`), because Bot Social applies neither half of an invite again on an existing relationship: the inviter's first line to the joining Bot was held as an unknown recipient until that side shared again. It is the half only the joining home can create, so it is best effort — a server without the tool or REST route leaves the accept successful and `sharedBack` absent — and the Add friend line says ` is shared back, so they can reach you too.` instead of only `You already have …`. - **#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. - **#666:** A friendship can be removed outside this plugin — the agent's `remove_friend` tool, or the Bot Social console — and the cached roster used to keep that Bot for up to `REMOTE_TTL_MS` (30 s). A send in that window was accepted and held, and approving it lost the line: Bot Social answered `send_as_member: no active relationship between these bots`. The send policy now asks Bot Social about the friendship itself before it asks the owner: `friendshipActive` reads `list_friends` for the home and for the sending Bot's own member identity (a per-Bot friendship, #420, is listed there), accepts only an entry whose `status` is `active` (or absent) and whose peer handle belongs to that friend's home, and caches the answer for 2 s so a burst of lines costs one call per friend, not one per line. No answer at all (an older server, a link that is down) keeps the roster's verdict: the check catches a removal, it does not invent one. When the friendship is gone the send is denied with the usual *is not a Bot a friend shared with you* reason, above the hold and Auto-send branches, and `forgetFriendHome` drops that friend from the cached roster and republishes, so the list and the picker stop offering a Bot that can no longer be reached. Room lines (`via: room` / `room-share`) skip the check. The other half is the cache: a `relationship.changed` event in either direction now invalidates the roster (`fetchedAt = 0`) **and** every cached friendship answer (`forgetFriendStatus`), so a send in the same millisecond is judged on a fresh answer rather than the two-second one — the earlier version only dropped the roster, and a send 500 ms after the event still read "still friends". Two more holes close with it (#666 review): an answer without a `friends` array is not evidence of a removal (it keeps the roster's verdict, or one server that changed shape would unfriend everyone), and releasing a held card re-asks `list_friends` with `{ fresh: true }` — bypassing the two-second answer at the moment the owner approves — so a card held before the removal fails with the *is not a Bot a friend shared with you* reason instead of being handed to a server that refuses it with `no active relationship`. The probe also carries its own `FRIEND_CALL_MS = 1.5 s` deadline through `callTool(name, args, { timeoutMs })`, because it runs on the send path and must not add the client's 30 s deadline to every line when Bot Social is down. Tests: `plugins/social/test/social.test.js` (`#666`, including the +0 ms and +500 ms cases). diff --git a/plugins/social/client.js b/plugins/social/client.js index 4cef1f9a..0313ba73 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -2891,6 +2891,12 @@ window.__ModuleLoader__.load({ const back = (r.restored ?? []).map(bot => botWithOwner(bot.name, bot.owner)).filter(Boolean) if (back.length) return `${back.join(' and ')} ${back.length > 1 ? 'are' : 'is'} back in your list.` const have = botWithOwner(r.botName, r.ownerName) + // #1000: a re-accept completes the pair now, so the line says what the click did instead of + // leaving the other direction silently absent. + if (r.sharedBack) { + const mine = r.sharedBackBotName || 'Your Bot' + return { tone: 'info', text: `${have ? `You already have ${have}. ` : ''}${mine} is shared back, so they can reach you too.` } + } return { tone: 'info', text: have ? `You already have ${have}.` : ALREADY_HAVE } } const who = r.inviterName ?? r.inviter ?? 'Your friend' @@ -2898,7 +2904,8 @@ window.__ModuleLoader__.load({ if (r.status === 'active') { // A join that brought no Bot says so instead of claiming one was shared, and stays open. if (shared) accepted = r - return shared ? `${who} shared ${shared} with you.` : `Joined ${who}'s invite, but their Bot hasn't loaded yet. It shows in your list when it does; if not, accept the link again.` + const backNote = r.sharedBack ? ` ${r.sharedBackBotName || 'Your Bot'} is shared back, so they can reach you too.` : '' + return shared ? `${who} shared ${shared} with you.${backNote}` : `Joined ${who}'s invite, but their Bot hasn't loaded yet. It shows in your list when it does; if not, accept the link again.${backNote}` } return `Request sent to ${who}. They show up here once they accept.` }) diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 47cf1368..67adb286 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -2334,7 +2334,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // Answer it as a result: which Bot it is (Bot Social's 409 names no Bot, so the public invite info // `GET /v1/invites/{code}` does; it needs no token), and which hidden or declined Bots really came // back into the list. Unhiding stays scoped to the invite's Bot/peer home, never every remote. - async function alreadyHave(mapped, code, before) { + async function alreadyHave(mapped, code, before, joining = {}) { let bot = mapped.bot?.handle ? mapped.bot : undefined let owner = cleanText(mapped.peer?.owner_display_name || mapped.peer?.display_name) if (!bot && typeof client.inviteInfo === 'function') { @@ -2348,6 +2348,11 @@ export function createSocial(service, config = {}, { client: given, api: givenAp warn('could not look up the invite after relationship_exists', error) } } + // #1000: a re-accept restores only the direction the invite sent, because Bot Social applies neither + // half of the invite again on a relationship that already exists. The half this side can create is the + // grant an accepted invite would have made; without it the inviter's first line to this Bot is held as + // an unknown recipient. Best effort: the friendship itself is already active either way. + const sharedBack = await restoreAcceptedInviteGrant({ bot: joining.bot, member: joining.member, inviter: bot }) const restored = [] try { const result = await client.callTool('list_remote_bots', { home: home.id }) @@ -2385,6 +2390,29 @@ export function createSocial(service, config = {}, { client: given, api: givenAp ...(botName ? { botName } : {}), ...(owner ? { ownerName: owner } : {}), restored, + ...(sharedBack ? { sharedBack: true, sharedBackBotName: joining.bot?.name } : {}), + } + } + // #1000: the grant an accepted invite makes on the joining side, asked for explicitly when the invite + // lands on a relationship that already exists (relationship_exists, or a `regranted` join). Bot Social + // applies the inviter's half only on a fresh relationship, so a re-accept left this home's Bot ungranted + // and the inviter's line to it held as an unknown recipient. A server without the tool (or with an older + // REST route) answers `unavailable`: the accept already happened, so that is logged, never thrown. + async function restoreAcceptedInviteGrant({ bot, member, inviter, inviterHome } = {}) { + if (!home || !bot || !member) return false + if (typeof api.grantBotToFriend !== 'function') return false + const peerId = cleanText(inviter?.bot_id) + const peerHome = norm(inviterHome || (inviter?.home ? inviter.home : '') || (inviter?.handle ? homeOfPeer(inviter.handle) : '')) + // The server resolves the friend by bot id, or by the home for a server that only knows that. + if (!peerId && (!peerHome || peerHome === '@')) return false + try { + const result = await api.grantBotToFriend({ botId: member.bot_id, ...(peerId ? { friendBotId: peerId } : {}), ...(!peerId ? { friendHome: peerHome } : {}) }) + if (result?.unavailable) return false + await syncOutgoingGrants({ only: bot.id }).catch(error => warn('grants not synced after a re-accept', error)) + return true + } catch (error) { + warn('the accepted invite could not be granted back', error) + return false } } // Joining a friend's invite (code or link). If the server asks this owner to approve @@ -2398,6 +2426,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const configuredHost = () => { try { return new URL(String(config.server)).host.toLowerCase() } catch { return '' } } let from = config.botId ? { from_bot_id: config.botId } : {} let joinedAs + // #1000: the Bot that accepts the invite, kept outside the `if` so a re-accept (the relationship already + // exists) can still grant its own Bot back — the half of the invite only this side can create. + let joiningBot + let joiningMember if (home) { const team = flybot.bot.list() const bot = typeof botId === 'string' && botId ? flybot.bot.get(botId) : team.find(item => item.isMain) ?? team[0] @@ -2406,6 +2438,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (!member) throw new Error(`${bot.name} has no Bot Social identity yet`) from = { from_bot_id: member.bot_id } joinedAs = { botId: bot.id, botName: bot.name, handle: norm(member.handle) } + joiningBot = bot + joiningMember = member } const before = new Set(listedRemoteBots().map(bot => bot.handle)) let joined @@ -2413,7 +2447,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp joined = await client.callTool('join_invite', { code: parsed.code, ...from }) } catch (error) { const mapped = mapJoinInviteError(error, { foreignHost: Boolean(parsed.host && configuredHost() && parsed.host !== configuredHost()) }) - if (mapped.code === 'relationship_exists' && home) return alreadyHave(mapped, parsed.code, before) + if (mapped.code === 'relationship_exists' && home) return alreadyHave(mapped, parsed.code, before, { bot: joiningBot, member: joiningMember }) throw mapped } // Bot Social answers with `inviter` = the invited Bot itself (a member handle, @chief.bob.home) and @@ -2427,6 +2461,13 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // sharing that Bot back and the re-filter drops the shared Bot until the next refresh (#228). if (offeredHandle) rememberInbound(offeredHandle) if (joined.relationship_id) rememberJoined(joined.relationship_id) + // #1000: a re-accept lands on a relationship that already exists, and Bot Social does not apply the + // invite's two halves again — the joining home's Bot is left ungranted, so the other side cannot reach + // it and its own first line is held as an unknown recipient. Accepting an invite is where both + // directions come from, so the half only this side can create is asked for explicitly. + const sharedBack = joined.regranted === true && joined.status === 'active' + ? await restoreAcceptedInviteGrant({ bot: joiningBot, member: joiningMember, inviter: offered, inviterHome: joined.inviter?.handle ? homeOfPeer(joined.inviter.handle) : undefined }) + : false // Recorded before the approval round trip below: if that answer is lost after Bot Social applied // it, the relationship can still become active, and a retry answers relationship_exists without // reaching this point again (Codex P2 on #387). @@ -2465,6 +2506,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp ...(inviterBotName ? { inviterBotName } : {}), ...(joinedAs ? { joinedAs } : {}), ...(joined.regranted ? { regranted: true } : {}), + ...(sharedBack ? { sharedBack: true, sharedBackBotName: joiningBot?.name } : {}), } })) disposers.push(flybot.api.handle('social/respond', async ({ relationshipId, decision } = {}) => { diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index a879c502..867d6327 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -5862,6 +5862,9 @@ test('#496: the panel shows an already-had Bot as neutral text, a restored one a const lastStatus = setters => { const value = setters.filter(([index, item]) => index === 2 && item !== null).at(-1)?.[1]; return value && { tone: value.tone, text: value.text } } for (const [reply, tone, expected] of [ [{ alreadyHave: true, status: 'active', botName: 'Chief', ownerName: 'SelfA1233', restored: [] }, 'info', 'You already have Chief (SelfA1233).'], + // #1000: the re-accept also shares the joining Bot back, and the neutral line says it. + [{ alreadyHave: true, status: 'active', botName: 'Chief', ownerName: 'SelfA1233', restored: [], sharedBack: true, sharedBackBotName: 'Chief' }, 'info', 'You already have Chief (SelfA1233). Chief is shared back, so they can reach you too.'], + [{ alreadyHave: true, status: 'active', restored: [], sharedBack: true, sharedBackBotName: 'Chief' }, 'info', "Chief is shared back, so they can reach you too."], [{ alreadyHave: true, status: 'active', restored: [] }, 'info', "You already have this friend's Bot."], [{ alreadyHave: true, status: 'active', botName: 'Chief', ownerName: 'SelfA1233', restored: [{ handle: '@chief.selfa1233.home', name: 'Chief', owner: 'SelfA1233' }] }, 'ok', 'Chief (SelfA1233) is back in your list.'], ]) { @@ -6719,6 +6722,9 @@ test('#343 / #499: an accepted invite shows its result, then the panel closes on const lastStatus = setters => setters.filter(([index, value]) => index === 2 && value !== null).at(-1)?.[1] for (const [reply, expected, closes] of [ [{ status: 'active', inviter: '@helper.alice.home', inviterName: 'Alice', inviterBotName: 'Helper' }, /^Alice shared Helper with you\.$/, true], + // #1000: a re-accept shares the joining Bot back, so the line says so instead of leaving it silent. + [{ status: 'active', inviter: '@helper.alice.home', inviterName: 'Alice', inviterBotName: 'Helper', sharedBack: true, sharedBackBotName: 'Chief' }, /^Alice shared Helper with you\. Chief is shared back, so they can reach you too\.$/, true], + [{ status: 'active', inviterName: 'Alice', sharedBack: true, sharedBackBotName: 'Chief' }, /^Joined Alice's invite, but their Bot hasn't loaded yet.*Chief is shared back, so they can reach you too\.$/, false], [{ status: 'active', inviterName: 'Alice' }, /^Joined Alice's invite, but their Bot hasn't loaded yet/, false], [{ status: 'pending_target', inviterName: 'Alice' }, /^Request sent to Alice/, false], ]) { @@ -11535,3 +11541,95 @@ test('#1005: the client refuses a stored code that is not six digits, on the car const six = draw(room('123456'), '') assert.ok(six.some(n => n.props['data-flybot'] === 'face-wait-code-digits' && n.children.includes('123456'))) }) + +// #1000: a re-accept lands on a relationship that already exists, so Bot Social applies neither half of the +// invite again. Accepting an invite is where both directions come from ("chat both ways, accepted on join"), +// so the joining home asks for the grant an accepted invite would have made; without it the inviter's first +// line to this Bot is held as an unknown recipient (SelfTest checklist 2b on d2578496). +test('#1000: accepting a re-invite grants the joining Bot back, so the pair is two-way again', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer() + const granted = [] + server.tools.grant_bot_to_friend = a => { granted.push(a); return { grant_id: 'g-back', status: 'active' } } + server.tools.list_bot_grants = () => ({ grants: granted.length ? [{ id: 'g-back', grantee: '@chief.bob.home', name: 'Chief (@chief.bob.home)', relationship_id: 'r1' }] : [] }) + const wrapped = async (url, init = {}) => { + if (/\/v1\/invites\/[^/]+$/.test(String(url)) && (init.method ?? 'GET') === 'GET') { + return new Response(JSON.stringify({ code: 'LIVE1', status: 'active', inviter: { bot_id: 'x1', handle: '@chief.bob.home', display_name: 'Chief', owner_display_name: 'Bob' } }), { status: 200 }) + } + const body = String(url).endsWith('/mcp') && init.body ? JSON.parse(init.body) : undefined + if (body?.params?.name === 'join_invite') { + const text = JSON.stringify({ code: 'relationship_exists', message: 'you are already connected to this Bot (or a request is pending)', relationship_id: 'r1' }) + return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { isError: true, content: [{ type: 'text', text }] } }), { status: 200 }) + } + return server.fetch(url, init) + } + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath: join(home, 'flybot-social-settings.json'), server: 'https://s', token: 'owner-token', fetch: wrapped }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: wrapped }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + await call('flybot/social/remote-bots', { fresh: true }) + const exists = (await call('flybot/social/join-invite', { code: 'https://s/i/LIVE1' })).value + assert.equal(exists.alreadyHave, true) + assert.equal(exists.sharedBack, true, 'the re-accept completes the pair') + assert.equal(exists.sharedBackBotName, 'Chief') + assert.equal(granted.length, 1, 'one grant, the half an accepted invite would have made') + assert.equal(granted[0].friend_bot_id, 'x1', 'granted to the Bot the invite named') + const joinedMember = [...server.members.values()].find(item => item.bot_id === granted[0].bot_id) + assert.equal(joinedMember?.handle, '@chief.danyang.home', 'the joining Bot, never the home') + // The grant is on the joining Bot, so People with access lists the inviter and can Revoke it (#995). + const chief = ctx.flybot.bot.list().find(bot => bot.name === 'Chief') + const access = (await call('flybot/social/access', { botId: chief.id })).value + assert.equal(access.grants.length, 1) + assert.equal(access.grants[0].grantIds.includes('g-back'), true) +}) + +// The other shape of the same re-accept: Bot Social folds the invite into the relationship that already +// exists and answers `regranted: true`. Same asymmetry, same half to restore. +test('#1000: a regranted join also grants the joining Bot back', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer() + const granted = [] + server.tools.grant_bot_to_friend = a => { granted.push(a); return { grant_id: 'g-back', status: 'active' } } + 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 }) + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath: join(home, 'flybot-social-settings.json'), server: 'https://s', token: 'owner-token', fetch: server.fetch }, { + 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 call('flybot/social/remote-bots', { fresh: true }) + const chief = ctx.flybot.bot.list().find(bot => bot.name === 'Chief') + const joined = (await call('flybot/social/join-invite', { code: 'B2', botId: chief.id })).value + assert.equal(joined.regranted, true) + assert.equal(joined.sharedBack, true) + assert.equal(joined.sharedBackBotName, 'Chief') + assert.deepEqual(granted.map(a => a.friend_bot_id), ['x1']) +}) + +// A server that cannot grant back (an older Bot Social) must not fail the accept: the friendship is already +// active and the neutral sentence still shows — the same tolerance the share path has (#563). +test('#1000: an older server that cannot grant back never fails the accept', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer() + server.tools.grant_bot_to_friend = () => { throw Object.assign(new Error('MCP error -32602: Tool grant_bot_to_friend not found'), { code: 'mcp' }) } + 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 }) + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath: join(home, 'flybot-social-settings.json'), server: 'https://s', token: 'owner-token', fetch: server.fetch }, { + 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 call('flybot/social/remote-bots', { fresh: true }) + const joined = await call('flybot/social/join-invite', { code: 'B2' }) + assert.equal(joined.ok, true, joined.error?.message) + assert.equal(joined.value.regranted, true) + assert.equal(joined.value.sharedBack, undefined) +})