diff --git a/changelog.d/2612.fixed.md b/changelog.d/2612.fixed.md new file mode 100644 index 000000000..c4fae2044 --- /dev/null +++ b/changelog.d/2612.fixed.md @@ -0,0 +1 @@ +A group shared with a friend's Bot now shows the room's recent messages right after the share, and every message that arrives afterwards, instead of an empty room that stayed empty until the host's message count reached the number the share carried: the guest numbers its mirrored copy of the room locally, so it no longer takes the host's line counter as the point where its own history starts, and a malformed counter in a share can no longer be stored or blank the room ([#2612](https://github.com/ThinkFlowLab/FlyBot/issues/2612)). diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 17d614ab1..945274bf1 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -2520,7 +2520,8 @@ export function install(rt) { localBotId, ownerId, remoteRoomId: data.roomId, - joinedSeq: data.seq, + // #2612: `data.seq` is the host's counter. This guest numbers its own mirrored log from 1, so the + // wire value is never a floor here — the room starts at 0 and the history the join carries shows. ...(ticket && data.op === 'join' ? { ticket } : {}), ...(data.op === 'join' ? { roster: rosterOf(data, { sharedFrom: { remoteRoomId: data.roomId, inviterHome: event.fromHome, inviterHandle: event.fromHandle, invitedHandle: data.invitedHandle } }, event) } : {}), ...(typeof noticeOf(data, undefined, event) === 'string' ? { notice: noticeOf(data, undefined, event) } : {}), diff --git a/core/test/room-membership.test.js b/core/test/room-membership.test.js index 279e7000d..5d1ab5799 100644 --- a/core/test/room-membership.test.js +++ b/core/test/room-membership.test.js @@ -11,6 +11,7 @@ import { install as installAuthz } from '../src/host/authz.js' import { install as installReach } from '../src/host/reach.js' import { install as installRooms, currentParticipants, isCrossOwnerRoom, linesVisibleTo, roomVisibleTo } from '../src/host/room-membership.js' import { compactStatus, fitRoomLines, prepareRoomLine } from '../src/host/room-context.js' +import { encodeRoomShare } from '../src/shared/room-share.js' // Room membership (#50): humans and Chiefs share a room with distinct authorship. // Team mode stays disabled — these tests cover the membership surface only. @@ -512,3 +513,109 @@ test('P-305: the stored status stub survives the store round trip well formed', const turn = fitRoomLines(reloaded.rt.state.rooms.shared.log, { render: item => `${item.who}: ${item.text}`, prepare: prepareRoomLine }) assert.equal(turn.text.isWellFormed(), true, `the turn built from the reloaded log is not well formed: ${JSON.stringify(turn.text.slice(0, 160))}`) }) + +// #2612: a guest numbers its own mirrored log from 1, so the chat line numbers on the wire belong to the +// host's counter and are not a membership floor here. The join used to copy `data.seq` into +// `sharedFrom.joinedSeq`, which hid the very history the join carried. +// A mirrored line is stored fenced as stranger text, so visibility is asked by substring. +const visible = (lines, text) => lines.filter(line => typeof line.text === 'string' && line.text.includes(text)).length + +test('#2612: a joined Bot sees the history the share carried, not the host seq as its floor', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + // A room at the host's seq 202 whose join carries only its last two lines, the way `shareHistory` + // trims it: the counter and the count of lines on the wire have nothing to do with each other. + const join = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', + seq: 202, + history: [ + { who: 'Alice', text: 'the plan is ready', seq: 201, authorKind: 'human' }, + { who: 'Alice', text: 'Friday works', seq: 202, authorKind: 'human' }, + ], + }) + const first = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', text: join, localBotId: 'spec-b', + }, actorB) + assert.equal(first.handled, true) + assert.equal(first.room.seq, 2, 'the guest keeps its own line numbers, one per mirrored line') + const shown = rt.roomHistory(first.room.id, actorB) + assert.equal(shown.filter(line => line.text).length, 2, `the join's two lines are the whole history: ${JSON.stringify(shown.map(line => line.text))}`) + assert.equal(visible(shown, 'the plan is ready'), 1, `the first carried line is visible: ${JSON.stringify(shown.map(line => line.text))}`) + assert.equal(visible(shown, 'Friday works'), 1, `the last carried line is visible: ${JSON.stringify(shown.map(line => line.text))}`) +}) + +// #2612: the same floor used to swallow every line that arrived after the join, because the guest's next +// local seq (3) is far below the host's counter (202). The room stayed empty until 202 messages arrived. +test('#2612: a line that arrives after the join is visible in the joined Bot every time', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const join = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', + seq: 202, + history: [ + { who: 'Alice', text: 'the plan is ready', seq: 201, authorKind: 'human' }, + { who: 'Alice', text: 'Friday works', seq: 202, authorKind: 'human' }, + ], + }) + const first = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', text: join, localBotId: 'spec-b', + }, actorB) + assert.equal(first.handled, true) + const line = encodeRoomShare({ + op: 'line', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', + seq: 203, who: 'Alice', authorKind: 'human', authorId: 'alice', text: 'a new line after the join', + }) + const posted = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', text: line, localBotId: 'spec-b', + }, actorB) + assert.equal(posted.handled, true) + assert.equal(posted.room.seq, 3, 'the guest numbers the new line itself') + const shown = rt.roomHistory(first.room.id, actorB) + assert.equal(visible(shown, 'a new line after the join'), 1, `the new line is visible: ${JSON.stringify(shown.map(entry => entry.text))}`) +}) + +// #2612: the floor came from the peer's envelope verbatim, so `"abc"`, `{}` or `[1,2]` was written to +// `state.json` and compared against every line forever. The guest's own log holds numbers; the wire value +// must not reach the store. +test('#2612: a junk wire seq never lands in state.json and never empties the room', async (t) => { + const { rt, statePath } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const rawJoin = (roomId, seq) => `FLYBOT-ROOM-SHARE\n${JSON.stringify({ + v: 1, op: 'join', roomId, name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', + seq, + history: [{ who: 'Alice', text: 'before the junk', seq: 1 }, { who: 'Alice', text: 'still here', seq: 2 }], + })}` + const floors = (node, out = []) => { + if (Array.isArray(node)) node.forEach(item => floors(item, out)) + else if (node && typeof node === 'object') { + for (const [key, value] of Object.entries(node)) { + if (key === 'joinedSeq') out.push(value) + else floors(value, out) + } + } + return out + } + for (const [index, junk] of ['abc', {}, [1, 2]].entries()) { + const roomId = `alice-junk-${index}` + const out = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', text: rawJoin(roomId, junk), localBotId: 'spec-b', + }, actorB) + assert.equal(out.handled, true, `the junk join ${JSON.stringify(junk)} was accepted`) + const shown = rt.roomHistory(out.room.id, actorB) + assert.equal(visible(shown, 'still here'), 1, `the room is not empty for ${JSON.stringify(junk)}: ${JSON.stringify(shown.map(line => line.text))}`) + await rt.save() + const stored = JSON.parse(readFileSync(statePath, 'utf8')) + assert.equal(stored.rooms[out.room.id].sharedFrom.joinedSeq, 0, `the wire value ${JSON.stringify(junk)} is not the guest's floor`) + assert.deepEqual(floors(stored).filter(value => typeof value !== 'number'), [], `every stored member floor is a local number, saw ${JSON.stringify(floors(stored))}`) + } +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 7e0c14924..cda3c3139 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -357,7 +357,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - The room record is the only authority: `room.localInvites [{ ticket, handle, botId, expiresAt?, mintedAt, mintedBy, server?, url?, homes?, acceptedAt?, withdrawnAt? }]`. A link has **no deadline and is not single-use** (owner decision 2026-10-24, #892, Bot Social #65): anyone it is given to can join with it, each home once (`homes: { [home]: [seat handles] }`; a record from before keeps its one `acceptedBy` / `seats`, read through `homesOf`), until the owner withdraws it or the room is deleted. A link minted with an `expiresAt` (an older Bot Social, or before the change) keeps that deadline. Several may be live at once (#878); a record written before the list (`room.localInvite`) is moved into it on first read. `rt.mintRoomInvite(roomId, actor)` adds one (dead links that seated nobody are dropped then); `rt.localInvitesOf(roomId)` lists the open ones (not withdrawn, not out of date; a link someone joined with stays open) and `rt.localInviteOf(roomId)` is the newest; `rt.roomOfLocalInvite(ticket)` resolves the room from the ticket; `rt.revokeRoomInvite(roomId, ticket?)` withdraws one link, or without a ticket every open one: it sets `withdrawnAt` (nobody new joins; the server ticket is revoked without `endClaims`), the seats it made stay, and a withdrawn link that seats nobody is dropped. A shared (`inbound-share`) or face-to-face room refuses to mint — it has its own invitation. - Three steps on the same envelope, answered by the room record: the invitee's host sends `{ op: 'ask', ticket }`; the inviter answers `{ op: 'invite', ticket, roomId, name, lead, expiresAt }`, which is information only (it consumes nothing and seats nobody); the accepting knock `{ op: 'ask', ticket, accept: true }` runs verify → consume → add → the existing `join`, which `addRoomParticipant` sends. `applyInboundRoomShare` handles both ops **before** the branch that projects a room for an unmatched `join` / `line`, so a knock never conjures a room; `parseRoomShare` accepts `ask` without a `roomId` and requires one on `invite`. The asker is `event.fromHandle` (the transport's sender), never a field of the payload. The accepting home is added to `homes`: the same home retrying is idempotent success (the lost `join` reply is sent again) and another home may join too (#892); an add the room refuses removes that home again. Refusals are local on the inviter's host (`unknown` / `revoked` / `expired` / `no-sender` / `not-addable`) and change nothing. The UI half — the Members card's **Invite by link** row and the dialog the offer draws — is still to come. Covered by `core/test/remote-room.test.js` (#852). - The endpoints the UI calls (#876): `room-invite` reads the live link (`rt.localInviteOf`, what the Members card copies and counts down), `room-invite-mint` and `room-invite-revoke` are the room owner's or a human admin's call (`authz.js`), and `room-invite-ask` is the invitee's side: `rt.askLocalInvite` reads the handle and ticket out of the link and sends `{ op: 'ask', ticket }` from one of the actor's own Bots whose home has a Bot Social identity — the link cannot name a sender, so the transport does, as on the answering side. Join is the same call with `accept: true`, and the answer arrives as `invite` through `applyInboundRoomShare` (`rt.onLocalInviteOffer`). A face-room link, a bare ticket and a ticket outside the 16–64 shape are refused in one sentence and send nothing; the knock carries no `roomId`, so an invitee can never name a room it has not seen. -- Strangers (#884, #878): when the social plugin registers Bot Social's ticket record (`flybot.rooms.serveInvites({ identify, mint, preview, claim, verify, revoke, endClaim })`, `rt.roomInviteServer`), a mint goes to the server (`server: true`; a server that fails refuses the mint — it is never swapped for a local ticket — and one without `local_room_invites` answers `undefined`, which keeps the friend-only local ticket). The link knocks on `invite.botId`, the room lead when it has a Bot Social identity; a lead with none gets one first through the seam's `identify` (the link is the owner's consent, as accepting a friend's invite is), and so do the Bots a guest brings on Join. A room-only identity (left by a face-to-face room) counts as none (#899): it is not polled and cannot send the knock, so `identify` promotes it, and when the seam is registered `rt.identifyInviteBots` promotes the Bot of every server link still open or holding a seat. `room-invite-preview` answers `roomId` and `joined` next to `claimed`: a claim is not a seat and a seat is not a claim — `joined` is true when this home holds the room, matched by that link's ticket or, since #1176, by `sharedFrom.remoteRoomId` for a room the host added one of these Bots to by add-member (no ticket), so a link for a room already here is not reported as a knock. On the invitee's side, Join (`room-invite-ask` with `accept: true` and `bots`, the invitee's own Bot ids) claims the ticket at the server for those Bots, all or none, then the first of them sends `{ op: 'ask', ticket, accept: true, roomId, bots }`; `room-invite-preview` (`rt.previewLocalInvite`) reads the server's public record before Join. On the inviter's side the knock is only a request: `seatByTicket` reads the claims back with this home's own credential (`verify` → `claimers`, every home that joined; an older server's single `claimer`) and seats exactly the Bots of the claim that brought the asker, from that home, in this room, or nothing — refusals `unclaimed` / `claimed-elsewhere` / `wrong-room` / `lookup-failed` / `expired` / `revoked` / `not-addable`, each its own. A seat is `{ remote: true, admittedBy: 'ticket', ticket, hostBotId }`: no published friend (`publishedRemoteOf` stays empty, nothing in the sidebar), and `notifyRoomShare` / `fanoutRoomLine` reach it from `hostBotId` only, with `meta.scope` = the room id so the transport sends it under that room (Bot Social's room reach). A Bot Social link is the minting owner's consent to show the room as it is: while a link is open or holds a seat, that owner's Bots pass `mayShareRoom` for the room (`reach.js openedByLink`). Lines and leaves a remote seat sends to a room of this home are ingested into it (`ownSeatOf` → `ingestSeatShare`: logged, fanned out to the other seats but not back to the sender's, called local Bots answer through `rt.startInboundTurn`); a `line` for a room of this home from anyone it does not seat is dropped (`not-seated`) and never projects a copy. Withdrawing a link does not end its seats (owner decision on #853). A seat ends when the host removes it or its home leaves (once that home's last seat is gone its claim ends at the server, `settleTicket` → `endClaim`; the link stays open for others), and when the room is deleted (`rt.dropRoomInvites`, called by `deleteRoom`, revokes every link with `endClaims`). On the guest's side a `join` naming a ticket projects only when this home claimed it: `state.roomClaims[ticket]` names that inviter (the transport's sender), that room, and the Bot the join names, else `unclaimed` and nothing is made. One projected room holds every Bot a ticket seated (`sharedFrom.ticket`; each `join` adds or revives only the Bot it names) and the owner as a human participant, so the person the link admitted can write there; a person's own line goes under their Bot Social owner name when this home only knows them as `You` or their member id (`seatSayer`, both directions). Ticket lines carry the text as written (`seatText`: a stored fence is opened, never nested) and the receiving home fences it once. a seated Bot's line goes from that Bot under the inviter's room id, and the last of its Bots leaving gives the claim back at the server. Reach: `reach.js ticketPeers(state)` lists the only pairs a link opens — `{ from, to, scope, ticket }` for a seat here (from its `hostBotId`), for a room of another home that seated Bots of mine (from those Bots to its inviter), and for a claim this home made (`state.roomClaims[ticket]`, the accepting knock's way out, dropped when the last seated Bot leaves or after a day unanswered). `reach()` allows a local → remote send that matches one exactly under `meta.scope` (the exact handle, never any Bot of that home), and the social plugin reads the same list (`flybot.rooms.ticketPeers()`) for its transport. A seat is enumerated from the link's own record of who it admitted, and `reach.js` reads that record through `homesOf` (`room-membership.js`) — one reading, used by both halves, so a link accepted before `homes` was written (`acceptedBy` + `seats`) enumerates the same pair and a room that predates the field does not lose it (#929); `linkSeats` stays read-only, writes no state and sends nothing. A join also carries `history`: the recent chat lines (`who`, `authorKind`, `seq`, unwrapped text, and a Bot's handle when this home has one). An absent field does not backfill (an older host); an empty list means there were no chat lines. The guest copies them into the mirror once, only while that log has no chat lines yet, and does not start a turn for them (`applyShareHistory`). A line that arrives after the join — on the guest mirror (`applyInboundRoomShare`) and on the host room (`ingestSeatShare`), and a guest Bot's own reply from `startInboundTurn` — is posted into the room Session with `rt.showSharedLine` (`displayOnly`). The conversation pane is that Session; `room.log` alone only feeds the sidebar preview, because the channel view strips `log`. The followup is not appended again, so it does not fan out a second time, and `openingSpeakers` returns nothing for it, so a name in the text does not open a round. A redelivery of the same remote seq does not draw a second bubble (`room.shownShare`). #1064: the knock is not the only way those seats are made. The social plugin's inbox poll calls `rt.seatOpenClaims` (`flybot.rooms.seatOpenClaims`), which `verify`s every open server link and seats each claiming home the same way, then sends `join` only for a seat whose notice has not yet left (`invite.told`). A lost, denied or unread knock still seats the guest, and the `join` opens the group on the guest's sidebar. A room that cannot be shown (`not-addable`), a notice waiting on approval (`held`) or one that does not leave (`not-delivered`, after a few tries) keeps `waiting` on the link (`reason`, and for `not-addable` the local Bot names). The Members card says so, and **Seat them** calls `room-invite-seat` (the room admin, same rule as mint), which runs that read again with the tries reset. #1161: a quiet poll reads one open server link about once a minute and, when the preview says `Too many attempts` (or answers `rate_limited` / `locked_out`), waits out the minutes that sentence names or the `Retry-After`, up to 10 minutes, so a home that stays open does not preview again before that cool-down elapses; **Seat them** and a read of one room still verify at once, and a preview that did not answer is tried again on the next tick. Every room-share text, a `join` and a `line` alike, is kept within Bot Social's 1–4000 character limit (`ROOM_SHARE_TEXT_MAX`): instruction-override lines are left out of its history and notice, then the oldest history, then roster entries, and only then the notice are dropped until it fits; a history or roster emptied only to fit is omitted, so it is not a false "no lines" or "no Bots", and an overflow a roster causes never empties the notice or deletes a present empty one, which is the host's own clear (#1932); since #1946 the roster also pays the 12 units the notice field costs on the wire even when it is empty, so a roster that stopped at a length where restoring the notice pushed the envelope back over `ROOM_SHARE_TEXT_MAX` no longer leaves the notice branch with nothing to cut and deletes the field — the host's clear now reaches the guest as a present empty notice instead of reading as "change nothing" ([#1946](https://github.com/ThinkFlowLab/FlyBot/issues/1946)). A seating send that is still `pending_approval` is `hold` and is not recorded as told; one whose hold is not listed yet is retried as not delivered. Covered by `core/test/room-ticket-seat.test.js` and the `#923`/`#929` cases in `core/test/remote-room.test.js`. +- Strangers (#884, #878): when the social plugin registers Bot Social's ticket record (`flybot.rooms.serveInvites({ identify, mint, preview, claim, verify, revoke, endClaim })`, `rt.roomInviteServer`), a mint goes to the server (`server: true`; a server that fails refuses the mint — it is never swapped for a local ticket — and one without `local_room_invites` answers `undefined`, which keeps the friend-only local ticket). The link knocks on `invite.botId`, the room lead when it has a Bot Social identity; a lead with none gets one first through the seam's `identify` (the link is the owner's consent, as accepting a friend's invite is), and so do the Bots a guest brings on Join. A room-only identity (left by a face-to-face room) counts as none (#899): it is not polled and cannot send the knock, so `identify` promotes it, and when the seam is registered `rt.identifyInviteBots` promotes the Bot of every server link still open or holding a seat. `room-invite-preview` answers `roomId` and `joined` next to `claimed`: a claim is not a seat and a seat is not a claim — `joined` is true when this home holds the room, matched by that link's ticket or, since #1176, by `sharedFrom.remoteRoomId` for a room the host added one of these Bots to by add-member (no ticket), so a link for a room already here is not reported as a knock. On the invitee's side, Join (`room-invite-ask` with `accept: true` and `bots`, the invitee's own Bot ids) claims the ticket at the server for those Bots, all or none, then the first of them sends `{ op: 'ask', ticket, accept: true, roomId, bots }`; `room-invite-preview` (`rt.previewLocalInvite`) reads the server's public record before Join. On the inviter's side the knock is only a request: `seatByTicket` reads the claims back with this home's own credential (`verify` → `claimers`, every home that joined; an older server's single `claimer`) and seats exactly the Bots of the claim that brought the asker, from that home, in this room, or nothing — refusals `unclaimed` / `claimed-elsewhere` / `wrong-room` / `lookup-failed` / `expired` / `revoked` / `not-addable`, each its own. A seat is `{ remote: true, admittedBy: 'ticket', ticket, hostBotId }`: no published friend (`publishedRemoteOf` stays empty, nothing in the sidebar), and `notifyRoomShare` / `fanoutRoomLine` reach it from `hostBotId` only, with `meta.scope` = the room id so the transport sends it under that room (Bot Social's room reach). A Bot Social link is the minting owner's consent to show the room as it is: while a link is open or holds a seat, that owner's Bots pass `mayShareRoom` for the room (`reach.js openedByLink`). Lines and leaves a remote seat sends to a room of this home are ingested into it (`ownSeatOf` → `ingestSeatShare`: logged, fanned out to the other seats but not back to the sender's, called local Bots answer through `rt.startInboundTurn`); a `line` for a room of this home from anyone it does not seat is dropped (`not-seated`) and never projects a copy. Withdrawing a link does not end its seats (owner decision on #853). A seat ends when the host removes it or its home leaves (once that home's last seat is gone its claim ends at the server, `settleTicket` → `endClaim`; the link stays open for others), and when the room is deleted (`rt.dropRoomInvites`, called by `deleteRoom`, revokes every link with `endClaims`). On the guest's side a `join` naming a ticket projects only when this home claimed it: `state.roomClaims[ticket]` names that inviter (the transport's sender), that room, and the Bot the join names, else `unclaimed` and nothing is made. One projected room holds every Bot a ticket seated (`sharedFrom.ticket`; each `join` adds or revives only the Bot it names) and the owner as a human participant, so the person the link admitted can write there; a person's own line goes under their Bot Social owner name when this home only knows them as `You` or their member id (`seatSayer`, both directions). Ticket lines carry the text as written (`seatText`: a stored fence is opened, never nested) and the receiving home fences it once. a seated Bot's line goes from that Bot under the inviter's room id, and the last of its Bots leaving gives the claim back at the server. Reach: `reach.js ticketPeers(state)` lists the only pairs a link opens — `{ from, to, scope, ticket }` for a seat here (from its `hostBotId`), for a room of another home that seated Bots of mine (from those Bots to its inviter), and for a claim this home made (`state.roomClaims[ticket]`, the accepting knock's way out, dropped when the last seated Bot leaves or after a day unanswered). `reach()` allows a local → remote send that matches one exactly under `meta.scope` (the exact handle, never any Bot of that home), and the social plugin reads the same list (`flybot.rooms.ticketPeers()`) for its transport. A seat is enumerated from the link's own record of who it admitted, and `reach.js` reads that record through `homesOf` (`room-membership.js`) — one reading, used by both halves, so a link accepted before `homes` was written (`acceptedBy` + `seats`) enumerates the same pair and a room that predates the field does not lose it (#929); `linkSeats` stays read-only, writes no state and sends nothing. A join also carries `history`: the recent chat lines (`who`, `authorKind`, `seq`, unwrapped text, and a Bot's handle when this home has one). An absent field does not backfill (an older host); an empty list means there were no chat lines. The guest copies them into the mirror once, only while that log has no chat lines yet, and does not start a turn for them (`applyShareHistory`). The mirror's membership floor is the guest's own log, never the envelope: a join starts it at 0, because the guest numbers its lines locally from 1 while the wire `seq` is the host's counter — copying that value hid the history the join carried and every line after it ([#2612](https://github.com/ThinkFlowLab/FlyBot/issues/2612)). A line that arrives after the join — on the guest mirror (`applyInboundRoomShare`) and on the host room (`ingestSeatShare`), and a guest Bot's own reply from `startInboundTurn` — is posted into the room Session with `rt.showSharedLine` (`displayOnly`). The conversation pane is that Session; `room.log` alone only feeds the sidebar preview, because the channel view strips `log`. The followup is not appended again, so it does not fan out a second time, and `openingSpeakers` returns nothing for it, so a name in the text does not open a round. A redelivery of the same remote seq does not draw a second bubble (`room.shownShare`). #1064: the knock is not the only way those seats are made. The social plugin's inbox poll calls `rt.seatOpenClaims` (`flybot.rooms.seatOpenClaims`), which `verify`s every open server link and seats each claiming home the same way, then sends `join` only for a seat whose notice has not yet left (`invite.told`). A lost, denied or unread knock still seats the guest, and the `join` opens the group on the guest's sidebar. A room that cannot be shown (`not-addable`), a notice waiting on approval (`held`) or one that does not leave (`not-delivered`, after a few tries) keeps `waiting` on the link (`reason`, and for `not-addable` the local Bot names). The Members card says so, and **Seat them** calls `room-invite-seat` (the room admin, same rule as mint), which runs that read again with the tries reset. #1161: a quiet poll reads one open server link about once a minute and, when the preview says `Too many attempts` (or answers `rate_limited` / `locked_out`), waits out the minutes that sentence names or the `Retry-After`, up to 10 minutes, so a home that stays open does not preview again before that cool-down elapses; **Seat them** and a read of one room still verify at once, and a preview that did not answer is tried again on the next tick. Every room-share text, a `join` and a `line` alike, is kept within Bot Social's 1–4000 character limit (`ROOM_SHARE_TEXT_MAX`): instruction-override lines are left out of its history and notice, then the oldest history, then roster entries, and only then the notice are dropped until it fits; a history or roster emptied only to fit is omitted, so it is not a false "no lines" or "no Bots", and an overflow a roster causes never empties the notice or deletes a present empty one, which is the host's own clear (#1932); since #1946 the roster also pays the 12 units the notice field costs on the wire even when it is empty, so a roster that stopped at a length where restoring the notice pushed the envelope back over `ROOM_SHARE_TEXT_MAX` no longer leaves the notice branch with nothing to cut and deletes the field — the host's clear now reaches the guest as a present empty notice instead of reading as "change nothing" ([#1946](https://github.com/ThinkFlowLab/FlyBot/issues/1946)). A seating send that is still `pending_approval` is `hold` and is not recorded as told; one whose hold is not listed yet is retried as not delivered. Covered by `core/test/room-ticket-seat.test.js` and the `#923`/`#929` cases in `core/test/remote-room.test.js`. ### Face-to-face owner send diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 17d614ab1..945274bf1 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -2520,7 +2520,8 @@ export function install(rt) { localBotId, ownerId, remoteRoomId: data.roomId, - joinedSeq: data.seq, + // #2612: `data.seq` is the host's counter. This guest numbers its own mirrored log from 1, so the + // wire value is never a floor here — the room starts at 0 and the history the join carries shows. ...(ticket && data.op === 'join' ? { ticket } : {}), ...(data.op === 'join' ? { roster: rosterOf(data, { sharedFrom: { remoteRoomId: data.roomId, inviterHome: event.fromHome, inviterHandle: event.fromHandle, invitedHandle: data.invitedHandle } }, event) } : {}), ...(typeof noticeOf(data, undefined, event) === 'string' ? { notice: noticeOf(data, undefined, event) } : {}), diff --git a/patches/ds-bot/files/test/room-membership.test.js b/patches/ds-bot/files/test/room-membership.test.js index 279e7000d..5d1ab5799 100644 --- a/patches/ds-bot/files/test/room-membership.test.js +++ b/patches/ds-bot/files/test/room-membership.test.js @@ -11,6 +11,7 @@ import { install as installAuthz } from '../src/host/authz.js' import { install as installReach } from '../src/host/reach.js' import { install as installRooms, currentParticipants, isCrossOwnerRoom, linesVisibleTo, roomVisibleTo } from '../src/host/room-membership.js' import { compactStatus, fitRoomLines, prepareRoomLine } from '../src/host/room-context.js' +import { encodeRoomShare } from '../src/shared/room-share.js' // Room membership (#50): humans and Chiefs share a room with distinct authorship. // Team mode stays disabled — these tests cover the membership surface only. @@ -512,3 +513,109 @@ test('P-305: the stored status stub survives the store round trip well formed', const turn = fitRoomLines(reloaded.rt.state.rooms.shared.log, { render: item => `${item.who}: ${item.text}`, prepare: prepareRoomLine }) assert.equal(turn.text.isWellFormed(), true, `the turn built from the reloaded log is not well formed: ${JSON.stringify(turn.text.slice(0, 160))}`) }) + +// #2612: a guest numbers its own mirrored log from 1, so the chat line numbers on the wire belong to the +// host's counter and are not a membership floor here. The join used to copy `data.seq` into +// `sharedFrom.joinedSeq`, which hid the very history the join carried. +// A mirrored line is stored fenced as stranger text, so visibility is asked by substring. +const visible = (lines, text) => lines.filter(line => typeof line.text === 'string' && line.text.includes(text)).length + +test('#2612: a joined Bot sees the history the share carried, not the host seq as its floor', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + // A room at the host's seq 202 whose join carries only its last two lines, the way `shareHistory` + // trims it: the counter and the count of lines on the wire have nothing to do with each other. + const join = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', + seq: 202, + history: [ + { who: 'Alice', text: 'the plan is ready', seq: 201, authorKind: 'human' }, + { who: 'Alice', text: 'Friday works', seq: 202, authorKind: 'human' }, + ], + }) + const first = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', text: join, localBotId: 'spec-b', + }, actorB) + assert.equal(first.handled, true) + assert.equal(first.room.seq, 2, 'the guest keeps its own line numbers, one per mirrored line') + const shown = rt.roomHistory(first.room.id, actorB) + assert.equal(shown.filter(line => line.text).length, 2, `the join's two lines are the whole history: ${JSON.stringify(shown.map(line => line.text))}`) + assert.equal(visible(shown, 'the plan is ready'), 1, `the first carried line is visible: ${JSON.stringify(shown.map(line => line.text))}`) + assert.equal(visible(shown, 'Friday works'), 1, `the last carried line is visible: ${JSON.stringify(shown.map(line => line.text))}`) +}) + +// #2612: the same floor used to swallow every line that arrived after the join, because the guest's next +// local seq (3) is far below the host's counter (202). The room stayed empty until 202 messages arrived. +test('#2612: a line that arrives after the join is visible in the joined Bot every time', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const join = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', + seq: 202, + history: [ + { who: 'Alice', text: 'the plan is ready', seq: 201, authorKind: 'human' }, + { who: 'Alice', text: 'Friday works', seq: 202, authorKind: 'human' }, + ], + }) + const first = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', text: join, localBotId: 'spec-b', + }, actorB) + assert.equal(first.handled, true) + const line = encodeRoomShare({ + op: 'line', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', + seq: 203, who: 'Alice', authorKind: 'human', authorId: 'alice', text: 'a new line after the join', + }) + const posted = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', text: line, localBotId: 'spec-b', + }, actorB) + assert.equal(posted.handled, true) + assert.equal(posted.room.seq, 3, 'the guest numbers the new line itself') + const shown = rt.roomHistory(first.room.id, actorB) + assert.equal(visible(shown, 'a new line after the join'), 1, `the new line is visible: ${JSON.stringify(shown.map(entry => entry.text))}`) +}) + +// #2612: the floor came from the peer's envelope verbatim, so `"abc"`, `{}` or `[1,2]` was written to +// `state.json` and compared against every line forever. The guest's own log holds numbers; the wire value +// must not reach the store. +test('#2612: a junk wire seq never lands in state.json and never empties the room', async (t) => { + const { rt, statePath } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const rawJoin = (roomId, seq) => `FLYBOT-ROOM-SHARE\n${JSON.stringify({ + v: 1, op: 'join', roomId, name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', + seq, + history: [{ who: 'Alice', text: 'before the junk', seq: 1 }, { who: 'Alice', text: 'still here', seq: 2 }], + })}` + const floors = (node, out = []) => { + if (Array.isArray(node)) node.forEach(item => floors(item, out)) + else if (node && typeof node === 'object') { + for (const [key, value] of Object.entries(node)) { + if (key === 'joinedSeq') out.push(value) + else floors(value, out) + } + } + return out + } + for (const [index, junk] of ['abc', {}, [1, 2]].entries()) { + const roomId = `alice-junk-${index}` + const out = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', text: rawJoin(roomId, junk), localBotId: 'spec-b', + }, actorB) + assert.equal(out.handled, true, `the junk join ${JSON.stringify(junk)} was accepted`) + const shown = rt.roomHistory(out.room.id, actorB) + assert.equal(visible(shown, 'still here'), 1, `the room is not empty for ${JSON.stringify(junk)}: ${JSON.stringify(shown.map(line => line.text))}`) + await rt.save() + const stored = JSON.parse(readFileSync(statePath, 'utf8')) + assert.equal(stored.rooms[out.room.id].sharedFrom.joinedSeq, 0, `the wire value ${JSON.stringify(junk)} is not the guest's floor`) + assert.deepEqual(floors(stored).filter(value => typeof value !== 'number'), [], `every stored member floor is a local number, saw ${JSON.stringify(floors(stored))}`) + } +})