From a9e87a64886070431becc13df193c6b21c14f7f0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 11:52:33 +0000 Subject: [PATCH] Register unpublished Bots at face-to-face join #87 registers only published Bots, so joining a face room with an unpublished Bot failed with "has no Bot Social identity yet". Register it on join with the same add_home_member path, keep it room-only (unpublished, not polled, not listed for friends), and revoke that identity when the Bot leaves its last face room. Co-authored-by: Danyang Chen --- CHANGELOG.md | 1 + docs/subsystems/social.md | 6 +- plugins/social/README.md | 13 ++- plugins/social/src/face-rooms.js | 26 ++++++ plugins/social/src/social.js | 99 ++++++++++++++++++----- plugins/social/test/face-rooms.test.js | 11 +++ plugins/social/test/social.test.js | 106 +++++++++++++++++++++++++ 7 files changed, 235 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2db9ba8fa..d8cc37331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to FlyBot (earlier: DS Bot) are listed here. The format foll ### Added +- **Face-to-face join registers an unpublished Bot for that room only.** If the Bot you bring has no Bot Social identity yet (typical after #51, which registers only published Bots), `@flybot/social` calls the same `add_home_member` path used for published Bots, then joins. The identity is not polled, not listed for friends, not used for invites, and does not flip **Published to friends**. `reach()` still denies anything outside the room. When you leave the last face room or the room closes, the room-only identity is revoked (`remove_home_member`); a failed join that created one is revoked too so retry is clean. Identities left from a previous publish stay unused. ([#83](https://github.com/ThinkFlowLab/FlyBot/issues/83)) - **Published to friends** in a Bot's settings (next to **Invite to this Bot**) is one click onto core `set-published` (`{ id, published }`, #51). Face-to-face join shows whether the Bot you bring is published (`not published, available to this room only`). ([#83](https://github.com/ThinkFlowLab/FlyBot/issues/83), [#51](https://github.com/ThinkFlowLab/FlyBot/issues/51)) - **Face-to-face group.** In Friends' Bots, **Join with a number** takes the same 6-digit number as the people next to you and which of your Bots to bring (default: first). Joining is direct — no preview. The room opens as a normal group with the full member list as ` · 's Bot` (owner badges from #82), one-click **Leave**, and **Start chat** (voids the number; a 10-minute countdown while it is still a candidate). Joins, leaves, start, and peer messages render as system / attributed lines; Bot replies go through Bot Social (`pending_approval` uses the existing Approve / Reject queue). **Auto-send my Bot's room messages** in the room header (and **Approve and always auto-send in this room** / **Turn on auto-send** on a waiting draft) is one click onto `auto_approve_face_rooms`; default off, easy to turn off again. Peer text is fenced. ([#83](https://github.com/ThinkFlowLab/FlyBot/issues/83)) - **Published Bots (#51).** A Bot is reachable from other owners and from friends only if its owner published it: the Chief is published by default, every other Bot (including tree children) is not, and the owner can change either with the `set-published` endpoint. Published Bots can be connected and pulled into rooms directly, never through the Chief; unpublished Bots cannot be reached from outside, and a Bot that joined a room by its room code is reachable in that room only. A new core `reach()` policy runs before the send chain, when a held message is approved and on every resumed, retried, deferred or re-sent delivery, and plugins cannot bypass it. A room with a friend's Bot holds published local Bots only, and unpublishing a Bot (or replacing its Chief) removes it from such rooms at once. `@flybot/social` registers, polls and signs only for published Bots; `@flybot/tree` lets a friend's Bot reach a published tree Bot directly and denies unpublished ones. New core patch `0013-published-reach`; plugins read `flybot.bot.isPublished`, `BotView.published` and the `bot.published` event. ([#51](https://github.com/ThinkFlowLab/FlyBot/issues/51), builds on #82) diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index cf3bcd55d..efa2df94a 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -10,7 +10,7 @@ The social plugin talks to a [Bot Social](https://github.com/Birfy/bot-social) s Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`. Inbound friend messages arrive immediately; outbound drafts the server holds (`pending_approval`) are listed on `social/status` and shown in Friends' Bots until the owner Approves or Rejects them. The same queue is also attached to a native shared room via `flybot.roomApprovals.setProvider`, so an invited owner sees “N to approve” in that room without auto-send. -Face-to-face rooms (#83) also live here: Friends' Bots has **Join with a number** (six digits, pick which local Bot, default first, join with no preview). The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the existing Approve / Reject path. **Auto-send my Bot's room messages** (room header and pending-approval) maps to `PATCH /v1/me { auto_approve_face_rooms }` so the owner's Bots can post without asking each time; default off. **Published to friends** in Bot settings (`flybot.bot.details`, next to Invite to this Bot) calls core `set-published` `{ id, published }` (#51). Face-to-face join uses `flybot.bot.isPublished` / `BotView.published` (`not published, available to this room only`). +Face-to-face rooms (#83) also live here: Friends' Bots has **Join with a number** (six digits, pick which local Bot, default first, join with no preview). The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the existing Approve / Reject path. **Auto-send my Bot's room messages** (room header and pending-approval) maps to `PATCH /v1/me { auto_approve_face_rooms }` so the owner's Bots can post without asking each time; default off. **Published to friends** in Bot settings (`flybot.bot.details`, next to Invite to this Bot) calls core `set-published` `{ id, published }` (#51). Face-to-face join uses `flybot.bot.isPublished` / `BotView.published` (`not published, available to this room only`). If the chosen Bot has no Bot Social identity yet (typically unpublished, because only published Bots are registered at start), join calls the same `add_home_member` path used for published Bots, then joins; that identity is room-only (not polled, not advertised to friends, not used for invites) and is revoked with `remove_home_member` when the Bot leaves its last face room or the room closes. Join never flips `published`. ## Owns @@ -40,9 +40,9 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers 1. Social code stays inside `plugins/social/`. 2. Uses documented plugin APIs only. 3. A server policy pause (`conversation_paused`: turn budget reached, loop suspected, emergency stop) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to an error that says resending cannot help and names the Bot Social console, and no other member Bot is tried for it. -4. Only **published** Bots (`flybot.bot.isPublished`, #51: the owner's choice, the Chief by default) have a Bot Social identity in use: `register` / `registerAll` skip an unpublished Bot, `pollOnce` reads inboxes of published Bots only, sends are signed only by published Bots (the middleware denies others), and invites / joining name a published Bot. Publishing a Bot registers it (`bot.published`); unpublishing keeps its identity on the server, unused, and does not revoke it. A core without `bot.isPublished` keeps the older all-Bots behavior. +4. Only **published** Bots (`flybot.bot.isPublished`, #51: the owner's choice, the Chief by default) have a Bot Social identity *in use*: `register` / `registerAll` skip an unpublished Bot, `pollOnce` reads inboxes of published Bots only, sends are signed only by published Bots (the middleware denies others), and invites / joining a friend name a published Bot. Publishing a Bot registers it (`bot.published`); unpublishing keeps its identity on the server, unused, and does not revoke it. **Exception — face-to-face join (#83):** an unpublished Bot with no identity is registered at join (`add_home_member`, same credentials as published Bots) so Bot Social can name it in that room. The field is marked `roomOnly`; friends' `list_remote_bots`, invites, polling and `reach()` outside the room stay denied, and the owner's `published` flag is unchanged. When that Bot leaves its last face room or the room closes, the plugin revokes the room-only identity (`remove_home_member`) so a leftover home member cannot leak. A failed join that created a new room-only identity is revoked too, so retry is clean. An identity that came from a previous publish is retained unused (#51), not treated as room-only. A core without `bot.isPublished` keeps the older all-Bots behavior. 5. When start fails (including Bot Social onboarding HTTP 409 `handle_taken`), Friends' Bots never shows the host's `Unknown FlyBot endpoint social/…` message. `apply` registers the social UI endpoints with a friendly `Social is not running: ` refusal, `flybot.warn` records the failure in diagnostics, and a 409 names the taken handle (`@.home`) and says to change `displayName` in the flybot-social config. The client also maps `bot/not-found` on `social/*` to that idle state. -6. Face-to-face join (#83) is direct (no preview). Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue (`N to approve` / Waiting for your approval) and the owner decides via `decide_approval`. **Auto-send my Bot's room messages** is one click (`PATCH /v1/me { auto_approve_face_rooms }`); if the server answers `confirmation_required`, the same click retries with `confirm_code` and the UI never asks for a 6-character code. New members only receive events from their join point (`after` cursor; the server also floors it). **Published to friends** (Bot settings) calls `set-published` `{ id, published }`. Join uses `flybot.bot.isPublished`. +6. Face-to-face join (#83) is direct (no preview). If the chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue (`N to approve` / Waiting for your approval) and the owner decides via `decide_approval`. **Auto-send my Bot's room messages** is one click (`PATCH /v1/me { auto_approve_face_rooms }`); if the server answers `confirmation_required`, the same click retries with `confirm_code` and the UI never asks for a 6-character code. New members only receive events from their join point (`after` cursor; the server also floors it). **Published to friends** (Bot settings) calls `set-published` `{ id, published }`. Join uses `flybot.bot.isPublished` and does not change it. ## Tests diff --git a/plugins/social/README.md b/plugins/social/README.md index 2d8a2481d..c1c54fea9 100644 --- a/plugins/social/README.md +++ b/plugins/social/README.md @@ -12,8 +12,15 @@ browser: `client.js`. Design: see "Social design: homes and members" in the root `Unknown FlyBot endpoint` message. The same failure is written to Settings → Feedback. - **Members**: each *published* local Bot becomes `@.` (`add_home_member`), stored as field `social.member`; a Bot is published by its owner (the Chief is by default, #51), registers when published - (`bot.published`), and deleted ones are revoked on `bot.removed`. Unpublished Bots get no identity, are not polled - and cannot sign sends or take invites; unpublishing keeps the identity unused instead of revoking it. + (`bot.published`), and deleted ones are revoked on `bot.removed`. Unpublished Bots are not registered at start, + are not polled, and cannot sign friend sends or take invites; unpublishing keeps the identity unused instead of revoking it. + **Face-to-face join** is the exception: if the Bot you bring has no identity yet, the plugin registers it with the + same `add_home_member` call (connector token, home id) so Bot Social can put it in that room. That identity is + room-only (`social.member.roomOnly`): it does not appear in friends' `list_remote_bots`, does not change the + owner's **Published to friends** flag, and `reach()` still denies anything outside the room. When you leave the + last face room or the room closes, the plugin revokes it (`remove_home_member`). A failed join that created a new + room-only identity is revoked too, so a retry is not blocked. An identity left over from a previous publish is + kept unused, not revoked. - **Remote roster**: `list_remote_bots` → friends' Bots grouped by home; sidebar section `flybot.sidebar.section#social` with a remote badge, `@` rows via `flybot.composer.mention#social`. Remote Bots never enter `bot.list()`. @@ -49,7 +56,7 @@ People in the same place (or on a call) put their Bots in one room by each typin 1. Open **Friends' Bots** in the sidebar. 2. Click **Join with a number**. Type six digits. If you have more than one Bot, pick which one to bring (default: the first). Click **Join**. -3. You join at once — there is no preview. FlyBot opens the room. Everyone already in it shows as ` · 's Bot` (your Chief: `'s Chief`). The join form says whether the Bot you bring is published; unpublished Bots are labeled “not published, available to this room only”. **Published to friends** (Bot settings, next to **Invite to this Bot**) is the owner's switch for that; it calls core `set-published` (#51). +3. You join at once — there is no preview. FlyBot opens the room. Everyone already in it shows as ` · 's Bot` (your Chief: `'s Chief`). The join form says whether the Bot you bring is published; unpublished Bots are labeled “not published, available to this room only”. If that Bot has no Bot Social identity yet, FlyBot registers it for this room only, then joins — friends still cannot reach it, and it is not published. **Published to friends** (Bot settings, next to **Invite to this Bot**) is the owner's switch for that; it calls core `set-published` (#51). 4. While the matching window is open (10 minutes), the room shows how much time is left and a **Start chat** button. Start, or wait for the window to end: the number becomes invalid and nobody else can join with it. 5. **Leave** removes your Bot in one click. Anyone can leave; if the last member leaves, the room closes and the number is free again. diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index bcf20d41b..5c0600fa9 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -95,6 +95,32 @@ const FRIENDLY = { dlp_blocked: 'That message was blocked and was not sent.', connector_token_rest_forbidden: 'Bot Social would not save that setting with this connection. Restart FlyBot or reconnect in Friends\' Bots.', confirmation_required: 'Bot Social asked for a confirmation. Try the toggle again.', + handle_taken: 'That Bot Social handle is already taken. Rename the Bot and try again.', +} + +// MCP add_home_member failures at face-to-face join: same friendly class as REST join errors, +// never a raw tool code. Registration is retried from a clean local state (no leftover member). +export function mapRegisterError(error, bot) { + if (error instanceof FaceRoomError) return error + const name = bot?.name ? String(bot.name) : 'this Bot' + const code = String(error?.code ?? '') + const msg = String(error?.message ?? error ?? '') + const retryAfter = error?.retryAfter ?? error?.retry_after + if (code === 'handle_taken' || /handle[_ ]taken|taken|exists|conflict|duplicate|in use|another Bot's Bot Social identity/i.test(msg)) { + return new FaceRoomError(`${name} could not join: ${FRIENDLY.handle_taken}`, 'handle_taken') + } + if (code === 'rate_limited' || code === 'http_429' || /429|rate.?limit/i.test(code + msg)) { + return mapFaceError({ error: { code: 'rate_limited', retry_after: retryAfter } }, 429) + } + if (code === 'timeout' || /no answer in|TimeoutError|AbortError/i.test(msg)) { + return new FaceRoomError(`Bot Social did not answer while registering ${name}. Try again.`, 'timeout') + } + if (code === 'http_401' || code === 'http_403' || code === 'unauthorized' || /401|403|unauthori[sz]ed|token/i.test(code + msg)) { + return new FaceRoomError(FRIENDLY.unauthorized, 'unauthorized', { status: 401 }) + } + const raw = msg.replace(/^Bot Social\s+\S+:\s*/, '').replace(/\s+/g, ' ').trim().slice(0, 200) + const detail = raw && !/^[a-z_]+$/.test(raw) ? `: ${raw}` : '. Try again.' + return new FaceRoomError(`Could not register ${name} on Bot Social${detail}`, code || 'register_failed') } export function mapFaceError(data, status) { diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 727c72b03..c3e69492d 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -5,7 +5,9 @@ // POST /v1/onboard/flybot on first start; the returned connector token is kept in // the credentials file (never in state.json). // - Every local Bot is a member identity @., keys custodied by the server. -// Bots are registered on start and on 'bot.created', revoked on 'bot.removed'. +// Published Bots are registered on start and on 'bot.created' / 'bot.published'. +// Unpublished Bots get a room-only identity at face-to-face join, revoked when they +// leave their last face room (or the room closes). Revoked on 'bot.removed'. // - Friendship is Bot-to-Bot: an invite names one local Bot and the friend joins as one of // theirs; each side sees and reaches only the Bots the friendship covers (the server's // list_remote_bots, with `via` naming the local Bots allowed to talk), shown in the roster (grouped by @@ -32,6 +34,7 @@ import { createFaceRoomApi, isJoinCode, mapFaceError, + mapRegisterError, renderFaceEvent, rememberEvent, FaceRoomError, @@ -321,17 +324,21 @@ export function createSocial(service, config = {}, { client: given, home: givenH // ------------------------------------------------------------ members const memberOf = botId => flybot.fields.get(botId, 'member') // A Bot gets an outside identity only if its owner published it (#51): only published Bots - // are registered, polled and sign sends. A core without bot.isPublished has no such rule. + // are registered at start, polled and sign friend sends. A core without bot.isPublished has + // no such rule. An unpublished Bot may still get a *room-only* identity at face-to-face join + // (`forFace`); that identity is not polled, does not sign friend sends or invites, and is + // revoked when the Bot is no longer in any face room (Bot Social `remove_home_member`). const published = botId => flybot.bot.isPublished?.(botId) !== false // The identity a published Bot signs and polls with; an unpublished Bot has none to use // (an identity it already holds is kept on the server, unused, so republishing needs no new handle). const activeMember = botId => (published(botId) ? memberOf(botId) : undefined) const unpublishedMessage = bot => `${bot.name} is not published: publish it before it can be invited, friend other Bots or be reached` + const isRoomOnlyMember = botId => Boolean(memberOf(botId)?.roomOnly) && !published(botId) // One registration per Bot at a time; a taken handle retries with the Bot's id suffix; // a Bot removed while it was registering has its new identity revoked. const registering = new Map() - function register(bot) { - if (!bot || !published(bot.id)) return Promise.resolve(undefined) + function register(bot, { forFace = false } = {}) { + if (!bot || (!forFace && !published(bot.id))) return Promise.resolve(undefined) if (!home || memberOf(bot.id) || flybot.readOnly?.()) return Promise.resolve(memberOf(bot.id)) if (!registering.has(bot.id)) { registering.set(bot.id, registerOnce(bot).finally(() => registering.delete(bot.id))) @@ -360,7 +367,13 @@ export function createSocial(service, config = {}, { client: given, home: givenH await client.callTool('remove_home_member', { home: home.id, member: member.bot_id }).catch(error => warn('revoke failed', error)) return undefined } - flybot.fields.set(bot.id, 'member', { bot_id: member.bot_id, handle: member.handle }) + // Room-only: unpublished at the moment of registration. A later publish reuses the handle + // and drops the flag so leave no longer revokes it. + flybot.fields.set(bot.id, 'member', { + bot_id: member.bot_id, + handle: member.handle, + ...(published(bot.id) ? {} : { roomOnly: true }), + }) return memberOf(bot.id) } // Every Bot at startup, once ds-bot's team is loaded (saved Bots fire no bot.created). @@ -384,8 +397,14 @@ export function createSocial(service, config = {}, { client: given, home: givenH } } disposers.push(flybot.on('bot.created', bot => { void register(bot).catch(error => warn('register failed', error)) })) - // Publishing gives the Bot its identity; unpublishing only stops it being used. - disposers.push(flybot.on?.('bot.published', bot => { if (bot.published) void register(bot).catch(error => warn('register failed', error)) }) ?? (() => {})) + // Publishing gives the Bot its identity; unpublishing only stops it being used. A room-only + // identity from a face-to-face join is kept and unmarked so leave does not revoke it. + disposers.push(flybot.on?.('bot.published', bot => { + if (!bot.published) return + const member = memberOf(bot.id) + if (member?.roomOnly) flybot.fields.set(bot.id, 'member', { bot_id: member.bot_id, handle: member.handle }) + void register(bot).catch(error => warn('register failed', error)) + }) ?? (() => {})) disposers.push(flybot.on('bot.removed', ({ bot }) => { const member = bot?.fields?.[NS]?.member if (home && member) void client.callTool('remove_home_member', { home: home.id, member: member.bot_id }).catch(error => warn('revoke failed', error)) @@ -928,15 +947,45 @@ export function createSocial(service, config = {}, { client: given, home: givenH } }) const bringable = () => localFaceBots().filter(bot => bot.handle) - function pickJoiner(botId) { - const team = bringable() + function pickJoiner(botId, { needHandle = true } = {}) { + const all = localFaceBots() + const team = all.filter(bot => bot.handle) if (typeof botId === 'string' && botId) { - const hit = team.find(bot => bot.id === botId) || localFaceBots().find(bot => bot.id === botId) + const hit = all.find(bot => bot.id === botId) if (!hit) throw new Error('Choose which of your Bots joins') - if (!hit.handle) throw new Error(`${hit.name} has no Bot Social identity yet`) + if (needHandle && !hit.handle) throw new Error(`${hit.name} has no Bot Social identity yet`) return hit } - return team[0] ?? localFaceBots()[0] + return team[0] ?? all[0] + } + function botByHandle(handle) { + const key = norm(handle) + return flybot.bot.list().find(bot => memberOf(bot.id)?.handle && norm(memberOf(bot.id).handle) === key) + } + function faceRoomsUsing(handle) { + const key = norm(handle) + return Object.entries(settings.faceRooms).filter(([, meta]) => meta?.asBot && norm(meta.asBot) === key) + } + // Room-only identities exist so Bot Social can name the Bot in a face room. They must not + // linger as reachable home members: revoke when the Bot is in no face room and still unpublished. + async function releaseRoomOnlyIdentity(botId) { + if (!isRoomOnlyMember(botId)) return + const member = memberOf(botId) + if (faceRoomsUsing(member.handle).length) return + if (home && member.bot_id) { + await client.callTool('remove_home_member', { home: home.id, member: member.bot_id }).catch(error => warn('revoke room-only identity failed', error)) + } + flybot.fields.set(botId, 'member', undefined) + } + async function registerForFace(bot) { + if (!bot) throw new FaceRoomError('Choose which of your Bots joins', 'invalid_request') + try { + const member = await register(bot, { forFace: true }) + if (member?.handle) return member + } catch (error) { + throw mapRegisterError(error, bot) + } + throw new FaceRoomError(`${bot.name} could not be registered on Bot Social. Try again.`, 'register_failed') } function viewHostedFace(room, extra = {}) { if (!room) return undefined @@ -1032,6 +1081,8 @@ export function createSocial(service, config = {}, { client: given, home: givenH if (known.has(serverId)) continue if (meta?.localRoomId) await flybot.rooms.dropFace?.(meta.localRoomId).catch(() => {}) delete settings.faceRooms[serverId] + const left = botByHandle(meta?.asBot) + if (left) await releaseRoomOnlyIdentity(left.id) } if (listed.length !== Object.keys(settings.faceRooms).length) saveSettings() } catch (error) { @@ -1044,21 +1095,25 @@ export function createSocial(service, config = {}, { client: given, home: givenH async function joinFaceRoom({ code, botId } = {}) { const digits = String(code ?? '').trim() if (!isJoinCode(digits)) throw mapFaceError({ error: { code: 'invalid_code' } }, 400) - const bot = pickJoiner(botId) + const bot = pickJoiner(botId, { needHandle: false }) if (!bot) throw new Error('Choose which of your Bots joins') - await register(flybot.bot.get(bot.id)) - const ready = pickJoiner(bot.id) - let snapshot + const local = flybot.bot.get(bot.id) + const hadIdentity = Boolean(memberOf(bot.id)) try { - snapshot = await faceNeed().join({ code: digits, bots: [ready.handle] }) + await registerForFace(local) + const ready = pickJoiner(bot.id) + const snapshot = await faceNeed().join({ code: digits, bots: [ready.handle] }) + const room = await applyFaceSnapshot(snapshot, { asBot: ready.handle }) + if (settings.faceRooms[snapshot.roomId]) settings.faceRooms[snapshot.roomId].asBot = ready.handle + saveSettings() + return { ...viewHostedFace(room, { joinedAs: { botId: ready.id, botName: ready.name, handle: ready.handle } }), faceRoomId: snapshot.roomId, status: snapshot.status, expiresAt: snapshot.expiresAt, members: snapshot.members } } catch (error) { + // A brand-new room-only identity that never made it into a room must not block retry + // (and must not linger as a reachable home member). + if (!hadIdentity) await releaseRoomOnlyIdentity(bot.id) if (error instanceof FaceRoomError) throw error throw error } - const room = await applyFaceSnapshot(snapshot, { asBot: ready.handle }) - if (settings.faceRooms[snapshot.roomId]) settings.faceRooms[snapshot.roomId].asBot = ready.handle - saveSettings() - return { ...viewHostedFace(room, { joinedAs: { botId: ready.id, botName: ready.name, handle: ready.handle } }), faceRoomId: snapshot.roomId, status: snapshot.status, expiresAt: snapshot.expiresAt, members: snapshot.members } } async function leaveFaceRoom({ roomId, faceRoomId, bots } = {}) { const id = faceRoomId || Object.entries(settings.faceRooms).find(([, item]) => item.localRoomId === roomId)?.[0] @@ -1067,6 +1122,8 @@ export function createSocial(service, config = {}, { client: given, home: givenH const meta = settings.faceRooms[id] if (meta?.localRoomId) await flybot.rooms.dropFace?.(meta.localRoomId).catch(() => {}) delete settings.faceRooms[id] + const left = botByHandle(meta?.asBot) + if (left) await releaseRoomOnlyIdentity(left.id) saveSettings() return { roomId: meta?.localRoomId, faceRoomId: id, status: result?.status || 'left' } } diff --git a/plugins/social/test/face-rooms.test.js b/plugins/social/test/face-rooms.test.js index 5aec7de54..5592d7421 100644 --- a/plugins/social/test/face-rooms.test.js +++ b/plugins/social/test/face-rooms.test.js @@ -20,6 +20,7 @@ import { publishedForJoin, createFaceRoomApi, FaceRoomError, + mapRegisterError, } from '../src/face-rooms.js' test('join code is exactly six digits', () => { @@ -52,6 +53,16 @@ test('error mapping covers the FACE_ROOMS.md codes', () => { assert.doesNotMatch(mapFaceError({ error: { code: 'nope', message: 'raw_sql' } }, 500).message, /raw_sql/) assert.match(mapFaceError({ error: { code: 'connector_token_rest_forbidden' } }, 403).message, /would not save that setting/) assert.equal(mapFaceError({ error: { code: 'confirmation_required', confirm_code: 'AB12CD' } }, 403).confirmCode, 'AB12CD') + assert.equal(mapFaceError({ error: { code: 'handle_taken' } }, 409).message, 'That Bot Social handle is already taken. Rename the Bot and try again.') + const taken = mapRegisterError(Object.assign(new Error('handle taken'), { code: 'handle_taken' }), { name: 'Writer' }) + assert.equal(taken.code, 'handle_taken') + assert.match(taken.message, /Writer could not join/) + assert.match(taken.message, /already taken/) + const limitedReg = mapRegisterError(Object.assign(new Error('Bot Social add_home_member: HTTP 429'), { code: 'http_429', retryAfter: 12 }), { name: 'Writer' }) + assert.match(limitedReg.message, /12 seconds/) + const generic = mapRegisterError(new Error('Bot Social add_home_member: boom_code'), { name: 'Writer' }) + assert.doesNotMatch(generic.message, /boom_code/) + assert.match(generic.message, /Could not register Writer/) assert.equal(formatRetryAfter(1), '1 second') assert.equal(formatRetryAfter(60), '1 minute') }) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index a0dc79696..9bff74ba0 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -1397,3 +1397,109 @@ test('face-to-face join uses core isPublished (Chief published, others not)', as assert.equal((await call('flybot/social/status', {})).value.bots.find(b => b.id === 'b').published, true) }) +test('face-to-face join registers an unpublished Bot for that room only', async (t) => { + const { ctx, call, tool, stop } = await team({ writerPublished: false }) + t.after(stop) + const server = withFaceRooms(mockServer()) + const polled = [] + const listInbox = server.tools.list_inbox + server.tools.list_inbox = (a) => { polled.push(a.bot_id); return listInbox(a) } + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await social.registerAll() + const scope = ctx.flybot.scope('social') + assert.equal(scope.fields.get('b', 'member'), undefined) + assert.equal(ctx.flybot.bot.get('b').published, false) + + const joined = (await call('flybot/social/face-join', { code: '123456', botId: 'b' })).value + assert.equal(joined.joinedAs.botId, 'b') + assert.equal(joined.joinedAs.handle, '@writer.danyang.home') + assert.equal(ctx.flybot.bot.get('b').published, false, 'join does not flip published') + assert.deepEqual(scope.fields.get('b', 'member'), { bot_id: 'm2', handle: '@writer.danyang.home', roomOnly: true }) + const status = (await call('flybot/social/status', {})).value + assert.equal(status.bots.find(bot => bot.id === 'b').published, false) + assert.equal(status.bots.find(bot => bot.id === 'b').handle, undefined, 'unpublished identity is not advertised as reachable') + + const friends = (await call('flybot/social/friends', {})).value + assert.equal(friends.some(bot => bot.handle === '@writer.danyang.home'), false) + const remote = (await call('flybot/social/remote-bots', { fresh: true })).value + assert.equal(remote.homes.flatMap(entry => entry.bots).some(bot => bot.handle === '@writer.danyang.home'), false) + + polled.length = 0 + await social.pollInbox() + assert.equal(polled.includes('m2'), false, 'room-only identity is not polled') + await assert.rejects(tool('message_friend_bot', { to: '@coder.bob.home', message: 'hi' }, 'b'), /not published/) + assert.equal((await call('flybot/social/create-invite', { botId: 'b' })).ok, false) + + const again = (await call('flybot/social/face-join', { code: '123456', botId: 'b' })).value + assert.equal(again.joinedAs.botId, 'b') + assert.equal(server.calls.filter(c => c.name === 'add_home_member' && c.args.name === 'writer').length, 1, 'join is idempotent') + + const left = (await call('flybot/social/face-leave', { faceRoomId: 'frm_123456' })).value + assert.equal(left.status, 'closed') + assert.equal(scope.fields.get('b', 'member'), undefined, 'room-only identity is revoked on leave') + assert.equal(server.calls.some(c => c.name === 'remove_home_member' && c.args.member === 'm2'), true) + assert.equal(ctx.flybot.bot.get('b').published, false) +}) + +test('face-to-face join registration failures are friendly and leave no leftover identity', async (t) => { + const { ctx, call, stop } = await team({ writerPublished: false }) + t.after(stop) + const server = withFaceRooms(mockServer()) + const add = server.tools.add_home_member + server.tools.add_home_member = (a) => { + if (String(a.name).startsWith('writer')) throw Object.assign(new Error('handle taken'), { code: 'handle_taken' }) + return add(a) + } + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await social.registerAll() + const scope = ctx.flybot.scope('social') + const taken = await call('flybot/social/face-join', { code: '123456', botId: 'b' }) + assert.equal(taken.ok, false) + assert.match(taken.error.message, /already taken/) + assert.doesNotMatch(taken.error.message, /handle_taken/) + assert.equal(scope.fields.get('b', 'member'), undefined) + + server.tools.add_home_member = (a) => { + if (String(a.name).startsWith('writer')) throw Object.assign(new Error('Bot Social add_home_member: HTTP 429'), { code: 'http_429', retryAfter: 20 }) + return add(a) + } + const limited = await call('flybot/social/face-join', { code: '123456', botId: 'b' }) + assert.equal(limited.ok, false) + assert.match(limited.error.message, /20 seconds/) + assert.equal(scope.fields.get('b', 'member'), undefined) + + server.tools.add_home_member = (a) => { + if (String(a.name).startsWith('writer')) throw new Error('Bot Social add_home_member: upstream_boom') + return add(a) + } + const failed = await call('flybot/social/face-join', { code: '123456', botId: 'b' }) + assert.equal(failed.ok, false) + assert.match(failed.error.message, /Could not register Writer/) + assert.doesNotMatch(failed.error.message, /upstream_boom/) + assert.equal(scope.fields.get('b', 'member'), undefined) + + server.tools.add_home_member = add + const full = await call('flybot/social/face-join', { code: '999999', botId: 'b' }) + assert.equal(full.ok, false) + assert.match(full.error.message, /8 Bots/) + assert.equal(scope.fields.get('b', 'member'), undefined, 'a brand-new room-only identity is revoked when join fails') + assert.equal(server.calls.some(c => c.name === 'remove_home_member'), true) + + const joined = (await call('flybot/social/face-join', { code: '123456', botId: 'b' })).value + assert.equal(joined.joinedAs.botId, 'b') + assert.equal(scope.fields.get('b', 'member').handle, '@writer.danyang.home') + + const refused = await call('flybot/social/face-join', { code: '999999', botId: 'b' }) + assert.equal(refused.ok, false) + assert.match(refused.error.message, /8 Bots/) + assert.equal(scope.fields.get('b', 'member').handle, '@writer.danyang.home', 'an identity already in a room is kept when another join fails') +}) +