From 22864772dba170dae31232d2c8ac9e81a7916671 Mon Sep 17 00:00:00 2001 From: Birfy Date: Tue, 20 Oct 2026 14:08:03 +0800 Subject: [PATCH] =?UTF-8?q?Social:=20neutral=20'You=20already=20have=20?= =?UTF-8?q?=E2=80=A6'=20on=20a=20re-accepted=20invite;=20clearer=20Remove?= =?UTF-8?q?=20confirm=20(#496)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On relationship_exists, social/join-invite answers { alreadyHave, botName, ownerName, restored } instead of throwing. The Bot is named from the public invite info (GET /v1/invites/{code}, no token), since Bot Social's 409 names no Bot. The panel shows neutral text 'You already have Chief (SelfA1233).' and '… is back in your list.' only when a hidden or declined Bot really came back. Remove confirm: 'Remove Chief (SelfB1233) from your list? Your own Bots stay shared. SelfB1233 can share it again.' --- changelog.d/496-already-have-invite.fixed.md | 1 + docs/subsystems/social.md | 7 +- plugins/social/client.js | 30 +++++-- plugins/social/src/mcp-client.js | 17 ++++ plugins/social/src/social.js | 87 ++++++++++++++------ plugins/social/test/social.test.js | 79 ++++++++++++++++-- 6 files changed, 181 insertions(+), 40 deletions(-) create mode 100644 changelog.d/496-already-have-invite.fixed.md diff --git a/changelog.d/496-already-have-invite.fixed.md b/changelog.d/496-already-have-invite.fixed.md new file mode 100644 index 00000000..9f5b44cc --- /dev/null +++ b/changelog.d/496-already-have-invite.fixed.md @@ -0,0 +1 @@ +Accepting an invite for a friend's Bot you already have no longer shows red error text claiming the Bot is back. It now says `You already have Chief (SelfA1233).` in neutral text, and says `… is back in your list.` only when a Bot you had removed really comes back. The **Remove this Bot** confirm now says what it does: `Remove Chief (SelfB1233) from your list? Your own Bots stay shared. SelfB1233 can share it again.` ([#496](https://github.com/ThinkFlowLab/FlyBot/issues/496)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 2e838dda..edba057a 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -88,7 +88,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - #194: a face room title shows one time: Bot Social's `created_at`, else the joining deadline minus the 10-minute window (`faceRoomCreatedAt`), the same before and after Enter chat and on every home. - #195: the Add friend paste button reads **Accept invite**; an accepted Bot invite posts a toast and closes the panel at once. - #191: a peer's leave reads `Bob left the room` (the person, from the member's owner name; the Bot label only when there is no owner name), and an ended face room shows a plain status line above the composer (`FaceRoomStatus` on `flybot.session.dock`, `data-flybot=face-room-end`: `Bob left the room. This room is closed.` / `You left this room.`). Joining by number opens the new room in the main pane at once, so the top bar never keeps the previous room's title. -- #197: while that status line shows, the composer is hidden (a CSS rule on the composer stack that has `data-flybot=face-room-end`), so a left or closed room is read-only. The account-menu handle uses normal ink. User-facing text no longer names a Friends' Bots section: `relationship_exists` reads `You're already friends. Their Bot is back in your list.`, and a held message to a friend's Bot says it waits for approval in this chat and names the Auto-send setting. +- #197: while that status line shows, the composer is hidden (a CSS rule on the composer stack that has `data-flybot=face-room-end`), so a left or closed room is read-only. The account-menu handle uses normal ink. User-facing text no longer names a Friends' Bots section: `relationship_exists` is not an error (#496, below), and a held message to a friend's Bot says it waits for approval in this chat and names the Auto-send setting. - My own handle (`@name.home`) shows with a **Copy** link at the top of the account menu (slot `flybot.account.header`, `MyHandle`) as well as in Add friend (#181). - Sidebar **+** is a short action menu, not the To:/composer. Add friend has two labelled groups (#343): **Accept an invite** (the full-width paste box `Paste invite link or code`, **Talk through [Bot]** when there is more than one Bot, then **Accept invite**) and **Invite a friend** (a **Share [Bot]** picker with the whole Bot name when there is more than one, then **Copy invite link for {Bot}**: mint + clipboard, and the link stays in a read-only field below it), with a `You are @your.home` line (#181) under the title and **Done** at the bottom (Escape also closes). After **Accept invite** the panel stays open with the result until **Done**: `{person} shared {Bot} with you.`, or, when the join brought no Bot, `Joined {person}'s invite, but their Bot hasn't loaded yet…` (#343). **Talk through** stays on one line (nowrap label). Bot Sharing puts the invite link on its own full-width row above one action row: **Copy link** (the one primary button), **Copy with instructions**, and a quiet **New link** text button (#230); before the first link there is only **Invite to this Bot**. The **Sharing** heading uses the same 4px inset as the core settings headings. The social strip keeps the **N to approve** badge and opens that chat; the stacked jump list stays out of the column. Held `face_room_message` drafts show in that face chat with readable names (`Chief → the room`), never raw `bot_01…` or a `FLYBOT-FACE-HUMAN` envelope. @@ -125,7 +125,8 @@ 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. -- **#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. +- **#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.`. - **#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. @@ -138,7 +139,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ ### Add friend invite input (#310) -`social/join-invite` runs the box text through `parseInviteInput` (`social.js`) before `join_invite`. It takes `https://host/i/CODE`, the same without the scheme, a link whose `/i/` is missing (host plus one bare token), a bare code, or the whole copied invite text (the first token with `/i/` or a URL wins), and sends only the code: Bot Social's `join_invite` looks up a code (it strips just a leading `…/i/`), so no host is ever built or added. Copy invite link hands out the server's `invite.url` through `inviteLink`, which rebuilds `origin/i/CODE` if a server ever answered a url without `/i/`; Bot Social itself always emits `/i/CODE` (`inviteUrl`) and serves only that route, so a link without `/i/` comes from hand-trimming or a client that dropped it. A pasted host that is not `config.server` is kept as `host`; a not-found then says the link may come from another server. `mapJoinInviteError` turns `invite_not_found`, expired / revoked / used invites, an own invite, `blocked` and `rate_limited` into one short sentence each ("This invite link wasn't found. It may be incomplete, expired, or already used. Ask your friend for a new link."); anything else passes through unchanged. The client (`plugins/social/client.js`) shows those sentences as written (#347): `errorText` relabels a message as "This invite was already used" only for the raw server words (`invite_unavailable`, `invite is used`, `invite_used`), never for the not-found sentence, which itself mentions "already used" as one possible cause; `run()` keeps its "… failed:" prefix for every other message and drops it only for the "already used" and "already friends" sentences. +`social/join-invite` runs the box text through `parseInviteInput` (`social.js`) before `join_invite`. It takes `https://host/i/CODE`, the same without the scheme, a link whose `/i/` is missing (host plus one bare token), a bare code, or the whole copied invite text (the first token with `/i/` or a URL wins), and sends only the code: Bot Social's `join_invite` looks up a code (it strips just a leading `…/i/`), so no host is ever built or added. Copy invite link hands out the server's `invite.url` through `inviteLink`, which rebuilds `origin/i/CODE` if a server ever answered a url without `/i/`; Bot Social itself always emits `/i/CODE` (`inviteUrl`) and serves only that route, so a link without `/i/` comes from hand-trimming or a client that dropped it. A pasted host that is not `config.server` is kept as `host`; a not-found then says the link may come from another server. `mapJoinInviteError` turns `invite_not_found`, expired / revoked / used invites, an own invite, `blocked` and `rate_limited` into one short sentence each ("This invite link wasn't found. It may be incomplete, expired, or already used. Ask your friend for a new link."); anything else passes through unchanged. The client (`plugins/social/client.js`) shows those sentences as written (#347): `errorText` relabels a message as "This invite was already used" only for the raw server words (`invite_unavailable`, `invite is used`, `invite_used`), never for the not-found sentence, which itself mentions "already used" as one possible cause; `run()` keeps its "… failed:" prefix for every other message and drops it only for the "already used" sentence; the neutral already-have sentence shows without error styling (#496). ### Thinking status for friends' Bots (#297) diff --git a/plugins/social/client.js b/plugins/social/client.js index b206641d..dea27f35 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -18,6 +18,10 @@ window.__ModuleLoader__.load({ const message = String(e?.message ?? e ?? '') return code === 'bot/not-found' || /Unknown FlyBot endpoint social\//i.test(message) } + // Re-accepting an invite for a Bot you already have is not an error (#496). + const ALREADY_HAVE = "You already have this friend's Bot." + // A friend's Bot with its owner: `Chief (SelfA1233)`, Bot first (#384). + const botWithOwner = (name, owner) => (name && owner && owner !== name ? `${name} (${owner})` : name || '') const errorText = (e) => { const code = String(e?.code ?? '') const http = code.match(/^HTTP_(\d+)$/) @@ -27,7 +31,7 @@ window.__ModuleLoader__.load({ return 'Social is not running: the plugin did not start. See Settings → Feedback.' } const message = String(e?.message ?? e ?? '') - if (/relationship_exists/i.test(message)) return "You're already friends. Their Bot is back in your list." + if (/relationship_exists/i.test(message)) return ALREADY_HAVE // Only the raw server words map here. The "invite wasn't found" sentence also says "already used" (as one of its possible // causes), so matching those words would relabel every unknown or mistyped link as a used one. if (/invite_unavailable|invite is used|invite_used/i.test(message)) return 'This invite was already used. Ask your friend for a new one.' @@ -1721,13 +1725,17 @@ window.__ModuleLoader__.load({ setBusy(true) setStatus(null) try { - const message = await fn() - if (message) setStatus({ tone: 'ok', text: message }) - await refresh(Boolean(message)) + // `fn` answers a sentence, or { tone, text } for a neutral note (#496). + const result = await fn() + const message = typeof result === 'string' ? { tone: 'ok', text: result } : result + if (message?.text) setStatus(message) + await refresh(Boolean(message?.text)) onChange?.() } catch (e) { const text = errorText(e) - setStatus({ tone: 'error', text: /^This invite was already used|^You're already friends/.test(text) ? text : `${label} failed: ${text}` }) + setStatus(text === ALREADY_HAVE + ? { tone: 'info', text } + : { tone: 'error', text: /^This invite was already used/.test(text) ? text : `${label} failed: ${text}` }) } finally { setBusy(false) } @@ -1756,6 +1764,13 @@ window.__ModuleLoader__.load({ return run('Accepting invite', async () => { const r = await call('social/join-invite', { code, ...(joiner ? { botId: joiner } : {}) }) setCode('') + if (r?.alreadyHave) { + // Nothing changed unless a hidden or removed Bot really came back (#496). + 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) + return { tone: 'info', text: have ? `You already have ${have}.` : ALREADY_HAVE } + } const who = r.inviterName ?? r.inviter ?? 'Your friend' const shared = r.inviterBotName || r.sharedBot if (r.status === 'active') { @@ -1949,7 +1964,8 @@ window.__ModuleLoader__.load({ setAuto(!on) } finally { setSaving(false) } } - const who = remote.homeName || remote.owner || home || 'Your friend' + const named = remote.homeName || remote.owner || '' + const who = named || home || 'Your friend' // Asked inline, like Leave on a face room (#230), not with the browser's confirm box. const removeBot = async () => { setAsking(false) @@ -1973,7 +1989,7 @@ window.__ModuleLoader__.load({ h('div', { className: 'fs-hint', 'data-flybot': 'friend-auto-hint' }, `Your Bots' replies to ${who} go out without asking you first. What you type is not affected.`)), h('div', { className: 'fs-friend-remove', 'data-flybot': 'friend-remove-row' }, asking ? h('span', { className: 'fs-leave-ask', 'data-flybot': 'remove-friend-bot-confirm' }, - h('span', { className: 'fs-hint' }, `Remove ${bot.name}? ${who} can share it again.`), + h('span', { className: 'fs-hint' }, `Remove ${botWithOwner(bot.name, named)} from your list? Your own Bots stay shared. ${who} can share it again.`), h('button', { type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': 'remove-friend-bot-yes', onClick: () => { void removeBot() } }, 'Remove'), h('button', { type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': 'remove-friend-bot-no', onClick: () => setAsking(false) }, 'Keep')) : h('button', { diff --git a/plugins/social/src/mcp-client.js b/plugins/social/src/mcp-client.js index 1b3e0131..1868ad8a 100644 --- a/plugins/social/src/mcp-client.js +++ b/plugins/social/src/mcp-client.js @@ -330,6 +330,22 @@ export function createSocialApi({ server, token, fetch: fetchImpl = socialFetch, return data } + // The public invite info (what the invite page shows): which Bot an invite shares and whose it is. + // No token goes with it; the route is public. Used only to name the Bot when a join answers + // relationship_exists, which names no Bot (#496). + async function inviteInfo(code) { + const value = String(code ?? '').trim() + if (!base || !value) return undefined + const response = await fetchImpl(`${base}/v1/invites/${encodeURIComponent(value)}`, { + method: 'GET', + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(timeoutMs), + }) + if (!response.ok) throw new BotSocialError(`Bot Social GET /v1/invites: HTTP ${response.status}`, `http_${response.status}`) + const data = JSON.parse(await response.text()) + return data && typeof data === 'object' ? data : undefined + } + async function tryTool(name, args, fallback) { try { return await mcp.callTool(name, args) @@ -443,6 +459,7 @@ export function createSocialApi({ server, token, fetch: fetchImpl = socialFetch, createInvite: args => mcp.callTool('create_invite', args), joinInvite: args => mcp.callTool('join_invite', args), listInvites: args => mcp.callTool('list_invites', args ?? {}), + inviteInfo, revokeInvite: args => mcp.callTool('revoke_invite', args), respondFriendRequest: args => mcp.callTool('respond_friend_request', args), removeFriend: args => mcp.callTool('remove_friend', args), diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 38977766..95cf1e2b 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -302,12 +302,17 @@ export function parseInviteInput(input) { return { code: code && INVITE_CODE.test(code) ? code : raw, ...(host ? { host } : {}) } } +// Neutral, not an error: re-accepting an invite for a Bot this home already has (#496). +export const ALREADY_HAVE = "You already have this friend's Bot." + export function mapJoinInviteError(error, { foreignHost } = {}) { const msg = String(error?.message ?? error ?? '') const code = String(error?.code ?? '') const blob = `${code} ${msg}` if (/relationship_exists/i.test(blob)) { - return Object.assign(new Error("You're already friends. Their Bot is back in your list."), { + // Nothing failed: this home already has the invite's Bot (#496). The join handler answers that as a + // result; this sentence is only the fallback when it cannot (no home on this server). + return Object.assign(new Error(ALREADY_HAVE), { code: 'relationship_exists', ...(error?.peer ? { peer: error.peer } : {}), ...(error?.bot ? { bot: error.bot } : {}), @@ -1889,6 +1894,63 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } } disposers.push(flybot.api.handle('social/status', socialStatus, { read: true })) + // `relationship_exists` on a join means this home already has the invite's Bot: nothing failed (#496). + // 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) { + 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') { + try { + const info = await client.inviteInfo(code) + if (info?.inviter?.handle) { + bot = info.inviter + owner = cleanText(info.inviter.owner_display_name) || owner + } + } catch (error) { + warn('could not look up the invite after relationship_exists', error) + } + } + const restored = [] + try { + const result = await client.callTool('list_remote_bots', { home: home.id }) + const homes = mapRemoteHomes(result?.homes) + const focus = norm(bot?.handle || mapped.peer?.handle) + const present = presentRemoteHandles(homes) + const handles = focus && focus !== '@' + ? present.filter(h => { + const homeKey = isHomeLevelHandle(focus) ? focus : homeOfPeer(focus) + return h === focus || h === homeKey || homeOfPeer(h) === homeKey + }) + : [] + const wasHidden = new Set(handles.filter(h => declinedRemotes.has(h) || isHiddenRemote(settings, h))) + if (handles.length) unhideDeclined(handles) + const next = handles.length ? revealHiddenIfPresent(settings, handles) : settings + if (next !== settings) { settings = next; saveSettings() } + fetchedAt = 0 + await remoteBots({ fresh: true }).catch(() => { /* the roster refreshes on the next poll */ }) + for (const item of listedRemoteBots()) { + if (wasHidden.has(item.handle) && !before.has(item.handle)) restored.push({ handle: item.handle, name: item.name, ...(item.owner || item.homeName ? { owner: item.owner || item.homeName } : {}) }) + } + if (!owner && bot?.handle) { + const entry = homes.find(item => item.home === homeOfPeer(norm(bot.handle))) + owner = cleanText(entry?.owner || entry?.name) + } + } catch (revealError) { + warn('could not unhide after relationship_exists', revealError) + } + const botName = cleanText(bot?.display_name || bot?.name) + return { + alreadyHave: true, + status: 'active', + ...(mapped.relationshipId ? { relationshipId: mapped.relationshipId } : {}), + ...(bot?.handle ? { bot: norm(bot.handle) } : {}), + ...(botName ? { botName } : {}), + ...(owner ? { ownerName: owner } : {}), + restored, + } + } // Joining a friend's invite (code or link). If the server asks this owner to approve // the request too, the click counts as that approval. // The friendship is between the invited Bot and the Bot that joins (botId, else the Main @@ -1915,28 +1977,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) { - try { - const result = await client.callTool('list_remote_bots', { home: home.id }) - const homes = mapRemoteHomes(result?.homes) - // Scope unhide to the invite's Bot/peer home — never every remote on the home. - const focus = norm(mapped.bot?.handle || mapped.peer?.handle) - const present = presentRemoteHandles(homes) - const handles = focus - ? present.filter(h => { - const homeKey = isHomeLevelHandle(focus) ? focus : homeOfPeer(focus) - return h === focus || h === homeKey || homeOfPeer(h) === homeKey - }) - : [] - if (handles.length) unhideDeclined(handles) - const next = handles.length ? revealHiddenIfPresent(settings, handles) : settings - if (next !== settings) { settings = next; saveSettings() } - fetchedAt = 0 - await remoteBots({ fresh: true }).catch(() => { /* the roster refreshes on the next poll */ }) - } catch (revealError) { - warn('could not unhide after relationship_exists', revealError) - } - } + if (mapped.code === 'relationship_exists' && home) return alreadyHave(mapped, parsed.code, before) throw mapped } // Bot Social answers with `inviter` = the invited Bot itself (a member handle, @chief.bob.home) and diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 2b1dcc62..f661f116 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -915,7 +915,7 @@ test('#230: removing a friend\'s Bot asks inline, not with the browser confirm b const { plugin, all, text } = clientPlugin([false, false, false, true, '']) const nodes = all(plugin.FriendChatSettings({ bot })) const ask = byId(nodes, 'remove-friend-bot-confirm') - assert.match(text(ask), /Remove Helper\? SelfA can share it again\./) + assert.equal(text(ask).replace(/RemoveKeep$/, ''), 'Remove Helper (SelfA) from your list? Your own Bots stay shared. SelfA can share it again.') assert.ok(byId(nodes, 'remove-friend-bot-yes')) assert.ok(byId(nodes, 'remove-friend-bot-no')) } @@ -5061,14 +5061,79 @@ test('#139: join errors are English and relationship_exists unhides', async (t) assert.equal(unavailable.ok, false) assert.match(unavailable.error.message, /already used/i) assert.doesNotMatch(unavailable.error.message, /invite_unavailable/) + // #496: not an error; it names the Bot and says what really came back. const exists = await call('flybot/social/join-invite', { code: 'EXISTS' }) - assert.equal(exists.ok, false) - assert.match(exists.error.message, /already friends/i) - assert.doesNotMatch(exists.error.message, /relationship_exists/) + assert.equal(exists.ok, true) + assert.equal(exists.value.alreadyHave, true) + assert.equal(exists.value.botName, 'Coder') + assert.equal(exists.value.ownerName, 'Bob') + assert.deepEqual(exists.value.restored, [{ handle: '@coder.bob.home', name: 'Coder', owner: 'Bob' }]) const again = (await call('flybot/social/remote-bots', { fresh: true })).value assert.equal(again.homes.some(h => h.bots.some(bot => bot.handle === '@coder.bob.home')), true) assert.equal(again.homes.some(h => h.bots.some(bot => bot.handle === '@helper.eve.home')), false, 'unrelated hidden Eve stays hidden') - assert.equal(mapJoinInviteError(Object.assign(new Error('relationship_exists'), { code: 'relationship_exists' })).message.includes('already friends'), true) + assert.equal(mapJoinInviteError(Object.assign(new Error('relationship_exists'), { code: 'relationship_exists' })).message, "You already have this friend's Bot.") +}) + +test('#496: re-accepting an invite for a Bot I already have names it from the public invite info, restores nothing, no token sent', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer() + // Like Bot Social: the 409 names no Bot (the MCP error carries code and message only). + const lookups = [] + const wrapped = async (url, init = {}) => { + if (/\/v1\/invites\/[^/]+$/.test(String(url)) && (init.method ?? 'GET') === 'GET') { + lookups.push({ url: String(url), authorization: init.headers?.authorization }) + return new Response(JSON.stringify({ code: 'LIVE1', status: 'active', inviter: { bot_id: 'x1', handle: '@coder.bob.home', display_name: 'Coder', 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' }) + assert.equal(exists.ok, true, exists.error?.message) + assert.equal(exists.value.alreadyHave, true) + assert.equal(exists.value.bot, '@coder.bob.home') + assert.equal(exists.value.botName, 'Coder') + assert.equal(exists.value.ownerName, 'Bob') + assert.deepEqual(exists.value.restored, [], 'nothing was hidden, so nothing came back') + assert.deepEqual(lookups.map(item => [item.url, item.authorization]), [['https://s/v1/invites/LIVE1', undefined]], 'the public invite info is read without the connector token') +}) + +test('#496: the panel shows an already-had Bot as neutral text, a restored one as back, the fallback never red', async () => { + 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).'], + [{ 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.'], + ]) { + const { plugin, all, setters } = clientPlugin([null, 'https://s/i/INV1', null, false, [{ id: 'a', name: 'Chief', isMain: true }], 'a', 'a', ''], { + call: async endpoint => (endpoint === 'social/join-invite' ? reply : {}), + }) + const nodes = all(plugin.AddFriend({ onDone() {} })) + await nodes.find(n => n.type === 'form').props.onSubmit({ preventDefault() {} }) + await new Promise(resolve => setTimeout(resolve, 10)) + assert.deepEqual(lastStatus(setters), { tone, text: expected }) + } + // An older plugin host that still throws relationship_exists: the same neutral sentence, no "failed:". + const { plugin, all, setters } = clientPlugin([null, 'https://s/i/INV1', null, false, [{ id: 'a', name: 'Chief', isMain: true }], 'a', 'a', ''], { + call: async endpoint => { if (endpoint === 'social/join-invite') throw new Error('Bot Social join_invite: relationship_exists'); return {} }, + }) + const nodes = all(plugin.AddFriend({ onDone() {} })) + await nodes.find(n => n.type === 'form').props.onSubmit({ preventDefault() {} }) + await new Promise(resolve => setTimeout(resolve, 10)) + assert.deepEqual(lastStatus(setters), { tone: 'info', text: "You already have this friend's Bot." }) + assert.doesNotMatch(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), /already friends/) }) test('#138: leaving a face room drops room-only remotes from Friends\' Bots', async (t) => { @@ -5845,7 +5910,7 @@ test('#139: Remove asks once (inline, #230); Keep leaves the Bot', () => { // The first click only opens the question; nothing is removed yet. walk(pluginOf([false, false, false, false, '']).FriendChatSettings({ bot })).find(n => n.props['data-flybot'] === 'remove-friend-bot').props.onClick() const open = walk(pluginOf([false, false, false, true, '']).FriendChatSettings({ bot })) - assert.match(open.find(n => n.props['data-flybot'] === 'remove-friend-bot-confirm').children.flatMap(c => c.children ?? [c]).join(' '), /Remove Helper\? Bob can share it again\./) + assert.match(open.find(n => n.props['data-flybot'] === 'remove-friend-bot-confirm').children.flatMap(c => c.children ?? [c]).join(' '), /Remove Helper \(Bob\) from your list\? Your own Bots stay shared\. Bob can share it again\./) open.find(n => n.props['data-flybot'] === 'remove-friend-bot-no').props.onClick() assert.equal(asked.length, 0) assert.equal(hidden.length, 0) @@ -6128,7 +6193,7 @@ test('#197: a read-only face room hides the composer, the own handle uses normal for (const text of [source, server]) { assert.doesNotMatch(text, /(?:return|Error\()[^\n]*in Friends' Bots/) } - assert.equal(mapJoinInviteError(Object.assign(new Error('relationship_exists'), { code: 'relationship_exists' })).message, "You're already friends. Their Bot is back in your list.") + assert.equal(mapJoinInviteError(Object.assign(new Error('relationship_exists'), { code: 'relationship_exists' })).message, "You already have this friend's Bot.") })