diff --git a/changelog.d/1612-face-start-miss.fixed.md b/changelog.d/1612-face-start-miss.fixed.md new file mode 100644 index 000000000..df1b1531c --- /dev/null +++ b/changelog.d/1612-face-start-miss.fixed.md @@ -0,0 +1 @@ +A face room no longer closes itself when the member list is still catching up: the "everyone left" count advances only on a poll, so the `face-start` response no longer spends its one poll of slack and a single lagging poll after **Enter chat** keeps the room open. The second agreeing poll, or a snapshot carrying the leave, still ends it ([#1612](https://github.com/ThinkFlowLab/FlyBot/issues/1612)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 20a4cd390..a1286ba5d 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -49,7 +49,7 @@ At phone width (`<= 640 px`, the last block in `client.js's stylesheet`) this pa 3. A server policy pause (`conversation_paused`: loop suspected between the Bots, emergency stop, or an older server's turn budget) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to *"Bot Social paused that chat (reason). Send your message again: it starts a new chat."* (structured: `code: 'conversation_paused'`, `paused`, `resend`), because `send_as_member` opens a fresh conversation for the pair's next message. It never points to the Bot Social website (`/ui`), which a FlyBot owner cannot sign in to, and never names Extend or turns (there is no turn limit, owner decision 2026-10-19, Bot Social #28; see below) and no other member Bot is tried for it. Other failures end in *Try again.*, never in a console pointer. Other `sendChat` failures are one short English line with a next step (reconnect, invite, try again) — never `Bot Social send_as_member: …`. A human's own message in a face-to-face or shared room to a friend's Bot that is already a member of that room is delivered as that human in the room (`onFaceLine` / room log); it is not signed or grant-checked as Chief (#143). Chief-originated `bot.send` / `message_friend_bot` to an ungranted friend Bot stays denied. The refusal says it the right way round and names the one setting that exists (#273): the sending Bot is not shared with that friend (*"Chief is not shared with that friend, so it cannot message their Bots. To allow it, share Chief with them in its settings → Sharing."*), or the target is not a Bot a friend shared with you; `message_friend_bot` tells the model to relay that reason as written. 4. Access is a per-Bot **grant** (#90, #138): default deny, Chief included. `register` / `registerAll` register a Bot when it is invited, when it joins an invite, when it already has a person-grant or a saved identity, or at face-to-face join (`forFace`, `roomOnly`). `pollOnce` reads inboxes of non-room-only members only. Friends' Bots is `list_remote_bots` (Bots granted **to me**), minus reverse-friends of invites I created and minus face-room-only remotes. Accepting **Sharing → Copy invite link** is the grant the other way; **Share with a friend** (`social/share-friends` + `social/share-with-friend`, via `grantBotToFriend` / `grant_bot_to_friend`) is the same grant for someone who is already a friend, with no invite code; both endpoints register the Bot's Bot Social identity on demand (`register(bot, { forInvite: true })`) when it has none yet, so the share list works on a Bot that was never invited or shared. Joining does not grant my talk-through Bot back. Join offers **Talk through** only when I have more than one Bot (default Chief; tooltip: not shared unless I invite them to it, #228). `social/access` + `social/revoke-grant` (via `mcp-client.js` `listGrants` / `revokeGrant`, preferring `list_bot_grants` / `revoke_bot_grant`) list people as `Name (@home)` and revoke one person. Core `reach()` / `flybot.grants` are the unbypassable gate. **Face-to-face join (#83 / #89):** bringing my own Bot by number is an implicit room-scoped grant. If it has no identity, join registers it room-only; friends' `list_remote_bots`, invites, polling and `reach()` outside the room stay denied. When that Bot leaves its last face room or the room closes, the plugin revokes the room-only identity (`remove_home_member`) and drops room-scoped remotes from Friends' Bots. A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and drops `roomOnly`, keeping the stored `ownerName`/`home` and filling either from `whoami` when the room-only record never had it (#863). Registration also records the owner-level `ownerName` and `home` on that member identity (#848) from one `whoami`: top-level `display_name` and `home`, else `connector_identity.bot_handles[0]`, else the `flybot-home` entry in `bots[]` (given the `@`), and `registerAll` fills them into an identity saved before this without registering it again; the group room-share envelope reads them to name the inviter. 5. First start retries a taken home handle (`FlyBot`, `FlyBot2`, …) instead of failing for a YAML edit (#136). If every retry is still taken, the social UI never shows the host's `Unknown FlyBot endpoint social/…` message: `apply` registers the social UI endpoints with a friendly `Social is not running: ` refusal and `flybot.warn` records the failure. The client also maps `bot/not-found` on `social/*` to that idle state. Add friend shows `You are @your.home` (#181). -6. Face-to-face join (#83, #92, #158) is a waiting room, not an instant chat: the owner picks local Bots, types the 6-digit number, then waits with the same code shown large until someone **Enter chat**s. Copy never says there is no preview. Each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a 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`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social, including `joinCode`) and draws one tile per person (#457: `FacePersonTile`, the person's name, `Me (you)` for the owner, with the Bots they brought underneath; the owner's tile has a tiny accent dot; the name and the Bot line wrap instead of a single-line ellipsis, and the full label stays on the tile's title, #1102); **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. 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 and in the chat (owner Approve / Reject). Owner-typed `FLYBOT-FACE-HUMAN` lines stay on that card until Approve / Always approve / Reject unless face **Auto-send** is on; the card and chat never show the raw envelope (#159). Face-room **Auto-send** is one switch per Bot of mine in the room (#457, `Auto-send Chief → room`, `social/face-auto { botId, on }`, kept in this home's `settings.faceAutoSend`, off by default): when that Bot's line comes back `pending_approval` and Bot Social says the hold is its own blanket rule (`hold_reason: 'blanket_outbound'`) or nothing but the room's Auto-send switch (`hold_reason: 'face_room_auto_send_off'` — what a clean face-room line carries once that switch is the only thing left), the host approves it at once (`decide_approval`; the switch is the owner's standing consent, so a switch that reads **on** has to send), so the room sees it; any other `hold_reason` (`risk`, `attachment`, `policy`, a value this build does not know) and other Bots' lines still wait for the owner. Only this face-room path accepts `face_room_auto_send_off` (`holdAllowsFaceAutoSend`, on top of `holdIsBlanket`); the two outbound paths keep the blanket rule alone (#865). The older account-wide `auto_approve_face_rooms` (`whoami` / MCP `set_auto_approve`) reads as every switch on; the first per-Bot change keeps the others on here and turns the account-wide one off (a `confirmation_required` answer is retried with `confirm_code` in the same click; the UI never asks for a code). REST `/v1/me` is not used with the connector token. A friend's Bot in the room member list and waiting grid uses the host Bot avatar (handle-seeded when Bot Social sends no image). New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. A number nobody else is using opens no room (#458): `social/face-join` without `start` answers `{ nobody: true, faceRoomId }` when the server's room holds only this home's Bots, and the host keeps that fresh candidate off the sidebar and Past rooms (an in-memory hold, dropped on the next poll after 2 minutes). The dialog's primary line is **Start a new room with …** and its primary button, listed first, is **Start a new room** (`start: true` adopts the held room through `GET`, no second create, #1053); **Cancel** (`social/face-leave { faceRoomId }` leaves it on Bot Social and releases a new room-only identity) is the other way out. The line does not read as a failed join. That answer is the only thing the host knows: a join that returns no other home is the same answer for an unused number and for a number whose room already opened, so the copy never claims nobody uses the number and the hint still points at an invite link (#999) — **Enter chat** from a lone waiting room is what burns a number. A room nobody from another home ever joined leaves no Past rooms entry and no kept transcript, whether the owner left it or the joining window ran out (the host then leaves it on Bot Social). The room stays open while two or more homes are in it and closes here only when this home is the last; the closed room's dock reads "… left the room. Room closed." with one **Close**. Leave and Close go back to the last non-face chat this browser showed (else the Main Bot) and close the details panel. Countdowns use the server's `expires_at` corrected by the Bot Social clock offset (from the `Date` header, applied only at 3 s or more) and catch up on `visibilitychange`. Join stays disabled until 6 digits; a number that is not empty and not six digits says **Enter 6 digits** under the field (`data-flybot=face-join-digits`, zh 请输入 6 位数字, #1052) while an empty field stays quiet, an outside click closes the dialog like Escape, and Enter chat steps aside while Leave asks. Core's sidebar leads a face-room row with its number and keeps face-room turns out of a Bot row's group-work line.7. Every import in shipped sources stays inside the plugin package: an installed plugin cannot reach into other packages' files (a repo-relative `../..` into `core/` breaks the installed bundle). +6. Face-to-face join (#83, #92, #158) is a waiting room, not an instant chat: the owner picks local Bots, types the 6-digit number, then waits with the same code shown large until someone **Enter chat**s. Copy never says there is no preview. Each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a 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`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social, including `joinCode`) and draws one tile per person (#457: `FacePersonTile`, the person's name, `Me (you)` for the owner, with the Bots they brought underneath; the owner's tile has a tiny accent dot; the name and the Bot line wrap instead of a single-line ellipsis, and the full label stays on the tile's title, #1102); **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. 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 and in the chat (owner Approve / Reject). Owner-typed `FLYBOT-FACE-HUMAN` lines stay on that card until Approve / Always approve / Reject unless face **Auto-send** is on; the card and chat never show the raw envelope (#159). Face-room **Auto-send** is one switch per Bot of mine in the room (#457, `Auto-send Chief → room`, `social/face-auto { botId, on }`, kept in this home's `settings.faceAutoSend`, off by default): when that Bot's line comes back `pending_approval` and Bot Social says the hold is its own blanket rule (`hold_reason: 'blanket_outbound'`) or nothing but the room's Auto-send switch (`hold_reason: 'face_room_auto_send_off'` — what a clean face-room line carries once that switch is the only thing left), the host approves it at once (`decide_approval`; the switch is the owner's standing consent, so a switch that reads **on** has to send), so the room sees it; any other `hold_reason` (`risk`, `attachment`, `policy`, a value this build does not know) and other Bots' lines still wait for the owner. Only this face-room path accepts `face_room_auto_send_off` (`holdAllowsFaceAutoSend`, on top of `holdIsBlanket`); the two outbound paths keep the blanket rule alone (#865). The older account-wide `auto_approve_face_rooms` (`whoami` / MCP `set_auto_approve`) reads as every switch on; the first per-Bot change keeps the others on here and turns the account-wide one off (a `confirmation_required` answer is retried with `confirm_code` in the same click; the UI never asks for a code). REST `/v1/me` is not used with the connector token. A friend's Bot in the room member list and waiting grid uses the host Bot avatar (handle-seeded when Bot Social sends no image). New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. A number nobody else is using opens no room (#458): `social/face-join` without `start` answers `{ nobody: true, faceRoomId }` when the server's room holds only this home's Bots, and the host keeps that fresh candidate off the sidebar and Past rooms (an in-memory hold, dropped on the next poll after 2 minutes). The dialog's primary line is **Start a new room with …** and its primary button, listed first, is **Start a new room** (`start: true` adopts the held room through `GET`, no second create, #1053); **Cancel** (`social/face-leave { faceRoomId }` leaves it on Bot Social and releases a new room-only identity) is the other way out. The line does not read as a failed join. That answer is the only thing the host knows: a join that returns no other home is the same answer for an unused number and for a number whose room already opened, so the copy never claims nobody uses the number and the hint still points at an invite link (#999) — **Enter chat** from a lone waiting room is what burns a number. A room nobody from another home ever joined leaves no Past rooms entry and no kept transcript, whether the owner left it or the joining window ran out (the host then leaves it on Bot Social). The room stays open while two or more homes are in it and closes here only when this home is the last; The peer-gone count advances only on a poll (`social/face-rooms`), never on the `face-start` / `face-join` / lead responses, which read the room once instead of observing it: one poll that still lags after **Enter chat** keeps the room open, the second agreeing poll ends it, and a snapshot carrying `member.left` still ends it at once (#1606, #1612). the closed room's dock reads "… left the room. Room closed." with one **Close**. Leave and Close go back to the last non-face chat this browser showed (else the Main Bot) and close the details panel. Countdowns use the server's `expires_at` corrected by the Bot Social clock offset (from the `Date` header, applied only at 3 s or more) and catch up on `visibilitychange`. Join stays disabled until 6 digits; a number that is not empty and not six digits says **Enter 6 digits** under the field (`data-flybot=face-join-digits`, zh 请输入 6 位数字, #1052) while an empty field stays quiet, an outside click closes the dialog like Escape, and Enter chat steps aside while Leave asks. Core's sidebar leads a face-room row with its number and keeps face-room turns out of a Bot row's group-work line.7. Every import in shipped sources stays inside the plugin package: an installed plugin cannot reach into other packages' files (a repo-relative `../..` into `core/` breaks the installed bundle). 8. A friend's Bot's reply to a line you typed (or to any request) goes back through `bot.send` as an `auto-reply` and, unless Auto-send is on for that pair, is held for the replying Bot's owner (#254). The hold is decided on the reply itself (#418): the host tags the auto-reply with the chat and turn it answers (`meta.replyIn`, `turns.js` → `hooks.js` `replyRemote`), the Bot's row in `state` lists its `heldReplies` (id, status, turn, recipient; owner only, no text) and counts a waiting one in `pendingCount`, and that turn's bubble is drawn held (dashed, `core/src/client/held-reply.js`) with the decision on it (`flybot.approvals` inline entry `social-held-reply`: **Waiting for approval**, `Chief → SelfB1238`, **Approve** / **Reject** and the **Auto-send from now on** link, which approves it and turns on Auto-send for that pair only, the replying Bot → that person, through `social/auto-send { approvalId }` (#417), with the same toast as the dock card; it never touches the home-wide switch). No dock draws it, in either chat; the overlay's pointer leads to that chat. A reply that left without a card because Auto-send is on for that pair (#417) reads **Auto-sent to SelfB1238** there, quietly, so the owner sees it went without approval: the middleware's `allow` says `autoSent` (with the same `recipient`) and the host keeps that mark by turn (`state.flybot.autoSent`, last 200, no text). After the decision the same place reads **Sent to SelfB1238** or **Not sent** (muted; **Retry** on a failed send, and only when the shared `canRetry()` allows it: this plugin's own dock and the core cards read that one predicate, so a terminal failure — the Bot is gone or no longer shared, the send was refused as not permitted — draws no button anywhere, #968), and the list preview shows the friend's line instead of a reply that did not go. The card names the person who gets it (#498): the holding middleware's `recipient` (the friend's owner name, stored as `recipientName`, so the card reads the same live and after a reload), else the owner of the friend's chat; never the friend's Bot (`Chief → Chief (SelfB1238)` named two Bots); a line held for a face-to-face room names that room instead of the bare `the room` (#991), the same way the room bullet below says. A hold made before the tag (no `replyIn`) keeps its dock card as below. A friend's handle is never a room id. The sender sees nothing until it is approved. The reply also goes back to a requesting Bot that was not shared with us (the default one-way per-Bot grant): the replying Bot's grant to it is enough for a `reply`, never for a new request (#272). 9. Two holds, one click (#258): a send also held by Bot Social (`pending_approval`) is finished by the transport when the host says the owner approved *this very send* on a FlyBot card (`msg.approved`, set by `hooks.js` `release` only; a Bot-supplied field is overwritten). The approval id comes from the `send_as_member` answer, is recorded as an own line (so a failed call is retried by the poll and never shows as a card) and is decided with the same `confirm_code` step as a typed line. Room lines (`via: room` / `room-share`) skip FlyBot's own hold: seating both Bots is the consent for ordinary room text (#1118). A send that left on Auto-send (#417) clears Bot Social's hold itself, the same way `autoSendFaceLine` does for a room line (#737): the owner's switch already answered that question, and leaving the hold parked the line with no card anywhere, so it never arrived. That clear only happens for the blanket hold and nothing else (#865): `holdIsBlanket()` reads Bot Social's `hold_reason` and clears the line only when it is `blanket_outbound`; every other value — `risk`, `attachment`, `policy`, a value this build does not know — keeps the line on its card, exactly as with Auto-send off. The face-room path is the one exception, and only for its own switch (#457): `autoSendFaceLine` clears `face_room_auto_send_off` too, while both outbound paths here never do. A room envelope is the other outbound path (#847, #1113, #1118): `autoSendRoomEnvelope` clears a blanket hold, and it also clears a room envelope whose `hold_reason` is `policy`, whose `reasons` are only the blanket rule (or empty) and which carries no risk flag, when the text is not an instruction override (`roomPolicyClears`). Pair Auto-send is not required. Both homes already seated the Bots, and that is the consent for ordinary room text, including the invited Bot's reply, so the other home is not left on "Waiting for the owner's approval" with no buttons. A direct message held as `policy` stays on its card. A DLP reason, a risk flag, an attachment, and an instruction-override in the group still wait. A list that has not caught up yet leaves the card; the next poll finishes that same room clear and no other, and only for an `outbound_message` whose text is a real room-share envelope. A face-room line (`face_room_message`) is not that clear: a `policy` hold still waits for the owner even when the Bot's text starts with the marker. The field wins when it and `reasons` disagree. A server that sends no field keeps the old allow-list: `reasons` must be non-empty and every entry exactly `mvp.all_outbound_requires_approval`, with no `risk_flags`, so a DLP category, `dlp.blocked`, a reason this build does not know (`policy.*`), an empty `reasons` list and any risk flag all keep the line (#766) — an absent field is not read as "not blanket", or every hold on an older server would strand. A hold Bot Social has not listed yet is not "no finding" either: the line waits and the report names the approval id (`warn`), so a lagging list is diagnosable instead of silent. It reads the pending list directly rather than through `pendingApprovals()`, which would also post the waiting notice for a hold it is about to clear, and a failed `decide_approval` only warns: the hold then shows as the owner's card. Holds that were already stuck before this change need one Approve / Reject in Friends' Bots. 10. Joining is not sharing (#336, Bot Social `faa7cb5`): Bot Social refuses a first message from a friend's Bot to one of my Bots that only joined their invite (`403 bot_not_shared`; nothing stored or delivered) and adds a `share.requested` event to my Bot's queue. `pollOnce` keeps it as one card per sender and Bot (`settings.shareRequests`, names cleaned, no peer content) and listed on `social/status` as `shareRequests`; Friends' Bots shows *"Helper (Eve) tried to start a chat with Writer. Writer is not shared with them."* with **Dismiss** (`social/share-request-dismiss`) and **Share Writer…**, which only opens that Bot's settings (Sharing). Nothing is granted from the card, no typed reply acts on it (#349) and the Bot never answers on its own; a card drops by itself once that Bot is shared with the sender (`flybot.grants.has`) or the Bot is gone. On the sender's side both send paths map `bot_not_shared` — and `capability_not_granted` only when the sentence is "peer has not granted chat.public", which is the same fact reported from the other side once a share is taken back (#971); the same code naming another capability, such as `profile.read`, stays a generic error and keeps Retry (#988) — to *"Coder (Bob) is not shared with your Bots, so the message was not delivered. Bot Social told its owner, who can share it with you. Sending again will not help until then."* (the transport prefixes `Not delivered:`), carrying `noRetry` so no card offers a Retry that cannot work, and with no retry through another member. Core `reach()` still refuses such an inbound message locally as the safety net. diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 1d3941e1f..762e2dc13 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -3996,12 +3996,12 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const mine = (snapshot?.members ?? hosted?.participants ?? []).find(member => locals.has(norm(member.handle))) return cleanText(mine?.ownerName || mine?.owner_name, '') || 'Someone' } - // #1606: one snapshot that lists no peer is not proof the peers left. Right after face-start - // the member list lags behind the seats, and a single dropped or half-read response looks the - // same. An open room ends only when two snapshots in a row agree that every other home is gone, - // or when a snapshot carries the leave itself; anything less than that must not close the room. - // `peerGone` is the caller's verdict from #1606: two snapshots in a row with no other home, or a - // snapshot carrying the leave itself. One peerless snapshot on its own never ends a room. + // #1606: one poll that lists no peer is not proof the peers left. Right after face-start the + // member list lags behind the seats, and a single dropped or half-read response looks the same. + // An open room ends only when two polls in a row agree that every other home is gone, or when a + // snapshot carries the leave itself; anything less than that must not close the room. + // `peerGone` is the caller's verdict: two polls in a row with no other home, or a snapshot + // carrying the leave itself. One peerless poll on its own never ends a room. function faceSnapshotPast(snapshot, hosted, peerGone = false) { const status = snapshot?.status || hosted?.face?.status if (status === 'closed' || status === 'left') return { status } @@ -4038,7 +4038,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp function neverShared(hosted) { return !(hosted?.participants ?? []).some(p => p.remote === true) } - function facePeersGone(snapshot, hosted, locals) { + function facePeersGone(snapshot, hosted, locals, { count = false } = {}) { const roomId = snapshot?.roomId const mine = new Set(locals.map(bot => bot.handle).filter(Boolean).map(norm)) const here = new Set() @@ -4053,21 +4053,25 @@ export function createSocial(service, config = {}, { client: given, api: givenAp for (const handle of here) known.add(handle) facePeers.set(roomId, known) const left = (snapshot?.events ?? []).some(event => event?.type === 'member.left') - const misses = here.size > 0 ? 0 : (facePeerMisses.get(roomId) ?? 0) + (known.size > 0 ? 1 : 0) + // #1612: only a poll observes the room over time, so only a poll advances the count; the start, + // join and lead responses reset it. Otherwise the start response spends the one poll of slack. + const misses = here.size > 0 || !count ? 0 : (facePeerMisses.get(roomId) ?? 0) + (known.size > 0 ? 1 : 0) facePeerMisses.set(roomId, misses) return known.size > 0 && here.size === 0 && (left || misses >= 2) } - // #1606: the seats a snapshot lists, and how many snapshots in a row each room has gone without - // them. Right after face-start the member list lags behind the seats, so one snapshot with no - // other home proves nothing; the room ends on the second agreeing snapshot or on the leave itself. + // #1606/#1612: the seats a snapshot lists, and how many polls in a row each room has gone + // without them. Right after face-start the member list lags behind the seats, so one poll with no + // other home proves nothing; the room ends on the second agreeing poll or on the leave itself. + // Only a poll advances that count: the start, join and lead responses read the room once rather + // than observe it over time, and the start response would otherwise spend the one poll of slack. const facePeers = new Map() const facePeerMisses = new Map() - async function applyFaceSnapshot(snapshot, { asBot } = {}) { + async function applyFaceSnapshot(snapshot, { asBot, poll } = {}) { if (!snapshot?.roomId || !flybot.rooms?.upsertFace) return undefined const attested = await serverAttests() const locals = bringable() const hosted = flybot.rooms.ofFace?.(snapshot.roomId) - const gone = snapshot.status === 'closed' || snapshot.status === 'left' ? false : facePeersGone(snapshot, hosted, locals) + const gone = snapshot.status === 'closed' || snapshot.status === 'left' ? false : facePeersGone(snapshot, hosted, locals, { count: poll === true }) const previousLive = new Set((hosted?.participants ?? []) .filter(p => p.leftSeq === undefined) .map(p => norm(p.handle || p.id)) @@ -4240,7 +4244,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const after = meta?.cursor ?? 0 const as = pollAsHandle(meta, listed) const snapshot = await faceNeed().get(serverId, { after, ...(as ? { as } : {}) }) - return applyFaceSnapshot(snapshot, { asBot: as || listedAsBot(snapshot) || listedAsBot(listed) }) + return applyFaceSnapshot(snapshot, { asBot: as || listedAsBot(snapshot) || listedAsBot(listed), poll: true }) } let facePolling async function pollFaceRooms() { diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 025652cbb..b410973b0 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -2612,27 +2612,27 @@ test('when the last peer leaves, the face room moves to Past rooms', async (t) = assert.equal(again.endedAt, past.endedAt) }) -// #1606: face-start seats the peer here at once while the server's member list still lags; one -// peerless snapshot must not end the room (it flipped to closed 1-3 s after face-start). -test('#1606: one snapshot with no peer listed does not close an open face room', async (t) => { +// #1606/#1612: face-start seats the peer here at once while the server's member list still lags, +// and the start response is itself a snapshot. Only polls may count toward a peer leave, so one +// lagging poll after start must not end the room; a second agreeing poll still does. +test('#1612: one lagging poll after face-start does not close the face room', async (t) => { const { ctx, call, server } = await faceSocial(t) - const room = server.face.peer('160616') - await call('flybot/social/face-join', { code: '160616' }) - room.status = 'open' - room.started_at = new Date().toISOString() - await call('flybot/social/face-rooms', { poll: true }) - assert.equal(ctx.flybot.rooms.ofFace('frm_160616').face.status, 'open') - // A snapshot whose member list has not caught up: the room must stay open and keep its seat. + const room = server.face.peer('161216') + await call('flybot/social/face-join', { code: '161216' }) + // #1606's lag: the peer is still seated here, but the server's member list has not caught up. room.members = [] - const first = (await call('flybot/social/face-rooms', { poll: true })).value.rooms.find(item => item.faceRoomId === 'frm_160616') - assert.equal(first.status, 'open', 'one peerless snapshot keeps the room open') - assert.equal(ctx.flybot.rooms.ofFace('frm_160616').face.status, 'open') + await call('flybot/social/face-start', { faceRoomId: 'frm_161216' }) + assert.equal(ctx.flybot.rooms.ofFace('frm_161216').face.status, 'open', 'face-start opens the room') + const first = (await call('flybot/social/face-rooms', { poll: true })).value.rooms.find(item => item.faceRoomId === 'frm_161216') + assert.equal(first.status, 'open', 'one lagging poll after face-start is not proof the peer left') + assert.equal(ctx.flybot.rooms.ofFace('frm_161216').face.status, 'open') assert.equal(room.status, 'open', 'the room was not left on Bot Social') - assert.equal(server.calls.some(c => c.name === 'REST POST /frm_160616/leave'), false, 'no face-leave for one snapshot') - // Two snapshots in a row that agree are a peer leave: the room ends and this home leaves it. - await call('flybot/social/face-rooms', { poll: true }) - assert.equal(ctx.flybot.rooms.ofFace('frm_160616').face.status, 'closed') - assert.ok(server.calls.some(c => c.name === 'REST POST /frm_160616/leave')) + assert.equal(server.calls.some(c => c.name === 'REST POST /frm_161216/leave'), false, 'no face-leave for one lagging poll') + // A second poll that still lists no peer is a real leave: the room ends and this home leaves it. + const second = (await call('flybot/social/face-rooms', { poll: true })).value.rooms.find(item => item.faceRoomId === 'frm_161216') + assert.equal(second.status, 'closed') + assert.equal(ctx.flybot.rooms.ofFace('frm_161216').face.status, 'closed') + assert.ok(server.calls.some(c => c.name === 'REST POST /frm_161216/leave')) }) // #1103: B left room 739184 and A's pane showed both "SelfB1025 left the room" and the