From 2aa88a8c12734aec51e0d4fc74d8745884245069 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:14:57 +0800 Subject: [PATCH] host: a seat change that moves the name reaches the guests already seated #1999 made an unnamed group's name follow its seats on this home, but the new name only ever left on an envelope addressed to the seat that changed, so a guest already seated kept the copy it arrived with while the host moved on. settleRoomName now answers whether the name moved, and the remote add and remove, a seat's own leave, the grant eviction and a link's claim send one join through reshareRoster when it did. The seat that changed is left out of that broadcast: its own targeted join is the one it is owed. --- .../2004-seat-rename-broadcast.fixed.md | 1 + core/src/host/room-membership.js | 53 +++-- core/test/remote-room.test.js | 211 +++++++++++++++++- docs/subsystems/core-host.md | 2 +- .../ds-bot/files/src/host/room-membership.js | 53 +++-- patches/ds-bot/files/test/remote-room.test.js | 211 +++++++++++++++++- 6 files changed, 498 insertions(+), 33 deletions(-) create mode 100644 changelog.d/2004-seat-rename-broadcast.fixed.md diff --git a/changelog.d/2004-seat-rename-broadcast.fixed.md b/changelog.d/2004-seat-rename-broadcast.fixed.md new file mode 100644 index 000000000..87256e7d6 --- /dev/null +++ b/changelog.d/2004-seat-rename-broadcast.fixed.md @@ -0,0 +1 @@ +A guest already seated in a group without a chosen name learns the new name as soon as another seat joins or leaves ([#2004](https://github.com/ThinkFlowLab/FlyBot/issues/2004)): before this the host moved its own name and Session title while the other homes kept the copy they arrived with, and only a guest arriving after the change was told, so the drawer and the group settings could still read a set of seats that had already changed. The seat that changed keeps its one targeted join; the guests already in the room get one more carrying the same text the host holds. A group with a chosen name, a mirror of another home's room and a face-to-face room still send nothing for a seat change. diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index abbbaefa6..cb1ef5619 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -422,12 +422,15 @@ export function install(rt) { // await still saves the right one; only the Session rename is deferred, and only when the name moved. // A mirror of another home's room takes its name from that home's envelope and a face-to-face room has // its own title, so neither is recomputed here. + // The answer says whether the name moved. The other homes only ever heard a name on an envelope + // addressed to the seat that changed, so a guest already seated keeps the copy it arrived with while + // this home moves on: a caller that sees `true` owes those guests one broadcast (#2004). const settleRoomName = (room) => { - if (room.named || room.origin === 'inbound-share' || isFaceRoom(room)) return Promise.resolve() + if (room.named || room.origin === 'inbound-share' || isFaceRoom(room)) return Promise.resolve(false) const next = rt.autoRoomName(room.members, room) - if (next === room.name) return Promise.resolve() + if (next === room.name) return Promise.resolve(false) room.name = next - return rt.retitleRoom(room) + return Promise.resolve(rt.retitleRoom(room)).then(() => true) } const materialize = (room) => { @@ -749,10 +752,14 @@ export function install(rt) { } } syncMembers(room) - await settleRoomName(room) + const moved = await settleRoomName(room) await rt.save() - if (record?.remote && record.handle) void rt.notifyRoomShare?.(room, { op: 'join', handle: record.handle }) - else if (record?.kind === 'bot' && !record.remote) reshareRoster(room) + if (record?.remote && record.handle) { + void rt.notifyRoomShare?.(room, { op: 'join', handle: record.handle }) + // #2004: the arrival moved the name and only the arriving seat heard it; the guests already in + // the room get the same name once, and the arriving seat keeps its one targeted join. + if (moved) reshareRoster(room, { except: record.handle }) + } else if (record?.kind === 'bot' && !record.remote) reshareRoster(room) return room }) } @@ -800,7 +807,7 @@ export function install(rt) { rt.dropGroupSession?.(room, id) } syncMembers(room) - await settleRoomName(room) + const moved = await settleRoomName(room) await rt.save() if (me.remote && isTicketSeat(me)) { await settleTicket(room, me.ticket, me).catch(error => rt.warn?.('ds-bot: a seat\'s leave was not settled: %s', error)) @@ -809,6 +816,9 @@ export function install(rt) { } else if (kind === 'bot' && !me.remote) { reshareRoster(room) } + // #2004: the leave moved the name, so the guests still in the room get the copy the host holds. The + // seat that left is already out of `currentParticipants`; a local Bot's leave sent its roster above. + if (me.remote && moved) reshareRoster(room) return room }) } @@ -1038,10 +1048,12 @@ export function install(rt) { // #904: a guest's copy learns this home's membership only from a fresh roster. A local add or remove // therefore has to send one; without this the guest list never changes and a removed Bot keeps writing. - function reshareRoster(room) { + // #2004: `except` is the seat a targeted join already reached, so a name that moved together with that + // seat costs it one envelope and not two. + function reshareRoster(room, { except } = {}) { if (!room || room.origin === 'inbound-share' || isFaceRoom(room)) return if (!(room.participants ?? []).some(p => p.remote)) return - void Promise.resolve(rt.notifyRoomShare?.(room, { op: 'join' })) + void Promise.resolve(rt.notifyRoomShare?.(room, except === undefined ? { op: 'join' } : { op: 'join', except })) .catch(error => rt.warn?.('ds-bot: a membership change was not shared: %s', error)) } @@ -1298,11 +1310,15 @@ export function install(rt) { // Membership notice: the add/remove click is the owner's consent, so this does not wait // for a chat line. Bot Social is 1:1; the envelope is the group record the other FlyBot sees. - rt.notifyRoomShare = async function notifyRoomShare(room, { op = 'join', handle } = {}) { + // #2004: `except` names the seats a targeted envelope already reached, so one seat change never costs + // the seat it changed two joins: its own, and the one that carries the name to the others. + rt.notifyRoomShare = async function notifyRoomShare(room, { op = 'join', handle, except } = {}) { if (!room || isFaceRoom(room) || room.origin === 'inbound-share') return [] const wanted = handle ? String(handle) : undefined + const skipped = except === undefined ? undefined : new Set((Array.isArray(except) ? except : [except]).map(String)) const remotes = (op === 'leave' ? (room.participants ?? []) : currentParticipants(room)) .filter(p => isRemoteParticipant(p) && (!wanted || p.handle === wanted || p.id === wanted)) + .filter(p => !skipped || !(skipped.has(String(p.handle ?? p.id)) || skipped.has(String(p.id)))) if (remotes.length === 0) return [] const from = shareInviter(room) const results = [] @@ -1787,10 +1803,13 @@ export function install(rt) { }) } syncMembers(room) - await settleRoomName(room) + const moved = await settleRoomName(room) // A poll reads this again every few seconds: a claim that is already seated must not rewrite the team. if (fresh.length > 0 || legacy || homesBefore !== JSON.stringify(homesOf(invite))) await rt.save?.() - return { fresh } + // #2004: the seats this claim just made are told by their own join, sent by the caller; the guests + // that were in the room before it are the ones the moved name has to reach. + if (moved) reshareRoster(room, { except: seats }) + return { fresh, moved } }) if (result.refused) return { refused: result.refused, bots: result.bots } return { seats, fresh: result.fresh } @@ -1989,12 +2008,15 @@ export function install(rt) { // and the sender must be that seat's own Bot; anything else is dropped without a trace in the room. async function ingestSeatShare(room, seat, data) { if (data.op === 'leave') { + let moved = false await withRoomLock(async () => { markSeatLeft(room, seat) syncMembers(room) - await settleRoomName(room) + moved = await settleRoomName(room) }) await rt.save?.() + // #2004: the seat left and the name moved with it; the guests still seated hear the new one. + if (moved) reshareRoster(room) if (isTicketSeat(seat)) await settleTicket(room, seat.ticket, seat) return { handled: true, room, left: seat.handle } } @@ -2262,6 +2284,7 @@ export function install(rt) { if (!Array.isArray(room.participants) || !(hasRemote(room) || isCrossOwnerRoom(room, state))) continue for (const me of currentParticipants(room).filter(p => p.kind === 'bot' && !p.remote && (botId === undefined || p.id === botId))) { if (mayShareRoom(state, room, me.id)) continue + let moved = false await withRoomLock(async () => { if (me.leftSeq !== undefined) return const line = append(room, { kind: 'left', who: me.displayName, authorId: me.id, authorKind: 'bot', text: '' }) @@ -2270,9 +2293,11 @@ export function install(rt) { if (room.seen) delete room.seen[me.id] rt.dropGroupSession?.(room, me.id) syncMembers(room) - await settleRoomName(room) + moved = await settleRoomName(room) changed = true }) + // #2004: the eviction moved this room's name; the guests still seated hear it once. + if (moved) reshareRoster(room) } } if (changed) await rt.save() diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 7de6635a3..2bd5b6116 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -3385,7 +3385,7 @@ test('a restart migrates an unnamed room title with the formula that sees the ro // no session machinery, so the two module-level paths are added here: a workspace registry, a Session // controller and a title recorder. function titledHost(t, saved) { - const { rt } = host(t, saved) + const { rt, sent } = host(t, saved) const titles = [] const workspaces = new Map() let sessions = 0 @@ -3406,7 +3406,7 @@ function titledHost(t, saved) { }) installRoster(rt) rt.resolveAgent = async id => ({ session: { id } }) - return { rt, titles } + return { rt, titles, sent } } // The one owner of TEAM; authz is not enforced until a member session exists, so a plain actor is enough @@ -3511,3 +3511,210 @@ test('#1995: an unnamed group without guests is renamed once per seat change', a assert.equal(titles.length, 1, 'one seat change is one rename, not a second from the settings save') assert.deepEqual(titles.map(entry => entry.name), ['Alice, Chief A, Chief B, Writer A']) }) + +// #2004: #1999 moved an unnamed group's name with its seats here, but the name only ever left this home +// on an envelope addressed to the seat that changed; a guest already seated kept the copy its last +// envelope carried. These cases read the other home's side the way `applyInboundRoomShare` does: an +// envelope addressed to a seat replaces that seat's copy of `name`, and a `join` is what a seat counts. +const WRITER = { + handle: '@writer.cy.home', + name: 'Writer', + home: '@cy.home', + homeName: 'Cy', + owner: 'Cy', + plugin: 'social', + remote: true, +} + +function guestSide(sent) { + const copies = new Map() + let cursor = 0 + return { + copies, + // The joins each seat received since the last read, and the name its copy ends on. + reads() { + const joins = new Map() + for (const msg of sent.slice(cursor)) { + const data = parseRoomShare(msg?.text) + if (!data || !msg.to) continue + if (typeof data.name === 'string') copies.set(msg.to, data.name) + if (data.op === 'join') joins.set(msg.to, (joins.get(msg.to) ?? 0) + 1) + } + cursor = sent.length + return joins + }, + } +} + +test('#2004: a seat change that moves the name reaches the guest already seated, once', async (t) => { + const { rt, titles, sent } = titledHost(t, TEAM) + await rt.load() + publish(rt, [CODER, WRITER]) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + const guest = guestSide(sent) + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: CODER.handle }, ALICE) + let joins = guest.reads() + assert.equal(guest.copies.get(CODER.handle), room.name, 'the arriving guest is told the name it arrives under') + assert.equal(joins.get(CODER.handle), 1, 'an arriving seat is told once, not twice') + + // A second guest sits: the guest already in the room has to end on the name the host holds, and one + // change may cost it one join — the arriving seat keeps its own targeted join and gets no second one. + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: WRITER.handle }, ALICE) + joins = guest.reads() + assert.equal(room.name, 'Alice, Chief A, Chief B, Coder, Writer') + assert.equal(guest.copies.get(CODER.handle), room.name, 'the guest already seated holds the name the host moved to') + assert.equal(joins.get(CODER.handle), 1, 'one seat change costs a seated guest one join') + assert.equal(guest.copies.get(WRITER.handle), room.name, 'the arriving guest is told the name it arrives under') + assert.equal(joins.get(WRITER.handle), 1, 'the arriving seat is told once, not twice') + + // The first guest leaves: the guest still seated has to hear whatever the name became. + await rt.removeRoomParticipant('war', { kind: 'bot', handle: CODER.handle }, ALICE) + joins = guest.reads() + assert.equal(room.name, 'Alice, Chief A, Chief B, Writer') + assert.equal(guest.copies.get(WRITER.handle), room.name, 'the guest that stayed holds the name the host holds after a leave') + assert.equal(joins.get(WRITER.handle), 1, 'one leave costs a seated guest one join') + + // And it sits again: the guest that stayed hears the name grow, the returning seat hears it once. + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: CODER.handle }, ALICE) + joins = guest.reads() + assert.equal(room.name, 'Alice, Chief A, Chief B, Writer, Coder') + assert.equal(guest.copies.get(WRITER.handle), room.name, 'the returning seat leaves the guest that stayed on the host name') + assert.equal(joins.get(WRITER.handle), 1, 'one return costs a seated guest one join') + assert.equal(guest.copies.get(CODER.handle), room.name, 'the returning guest is told the name it arrives under') + assert.equal(joins.get(CODER.handle), 1, 'the returning seat is told once, not twice') + assert.equal(titles.length, 4, 'the Session followed each of the four seat changes, no more') +}) + +test('#2004: a named group sends its seated guests nothing for a seat change', async (t) => { + const { rt, titles, sent } = titledHost(t, TEAM) + await rt.load() + publish(rt, [CODER, WRITER]) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + const guest = guestSide(sent) + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: CODER.handle }, ALICE) + let joins = guest.reads() + assert.equal(joins.get(CODER.handle), 1, 'the arriving seat gets the one join it is owed') + assert.equal(guest.copies.get(CODER.handle), 'War room') + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: WRITER.handle }, ALICE) + joins = guest.reads() + assert.equal(room.name, 'War room', 'a chosen name does not follow the seats') + assert.equal(joins.get(CODER.handle) ?? 0, 0, 'a name that did not move is not resent to a seated guest') + assert.equal(joins.get(WRITER.handle), 1, 'the arriving seat is told once') + + await rt.removeRoomParticipant('war', { kind: 'bot', handle: CODER.handle }, ALICE) + joins = guest.reads() + assert.equal(room.name, 'War room') + assert.equal(joins.get(WRITER.handle) ?? 0, 0, 'a leave that did not move the name sends nothing either') + assert.equal(titles.length, 0, 'and no Session is renamed for a chosen name') +}) + +test('#2004: a seat that leaves by its own envelope moves the name for the guests still seated', async (t) => { + const { rt, sent } = titledHost(t, TEAM) + await rt.load() + publish(rt, [CODER, WRITER]) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + const guest = guestSide(sent) + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: CODER.handle }, ALICE) + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: WRITER.handle }, ALICE) + guest.reads() + + // The guest's own home says it left; this home never sends that leave itself. + await rt.applyInboundRoomShare({ + text: encodeRoomShare({ op: 'leave', roomId: 'war', name: room.name, invitedHandle: CODER.handle, seq: room.seq ?? 0 }), + fromHandle: CODER.handle, + fromHome: '@bob.home', + localBotId: 'chief-a', + }) + const joins = guest.reads() + assert.equal(room.name, 'Alice, Chief A, Chief B, Writer', 'the leave moves the name here') + assert.equal(rt.state.rooms.war.participants.find(p => p.handle === CODER.handle)?.leftSeq !== undefined, true, 'the seat is gone from the roster') + assert.equal(guest.copies.get(WRITER.handle), room.name, 'the guest still seated hears the name the host holds') + assert.equal(joins.get(WRITER.handle), 1, 'one leave costs a seated guest one join') +}) + +test('#2004: a grant revoked under a guest moves the name for the guests still seated', async (t) => { + const { rt, titles, sent } = titledHost(t, TEAM) + await rt.load() + publish(rt, [CODER]) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + delete room.named + await grantToFriend(rt, 'spec-a') + rt.applyParticipants(room, [ + { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice' }, + { kind: 'bot', id: 'chief-a', role: 'admin' }, + { kind: 'bot', id: 'chief-b', role: 'member' }, + { kind: 'bot', id: 'spec-a', role: 'member' }, + { kind: 'bot', remote: true, handle: CODER.handle }, + ], ALICE) + room.name = rt.autoRoomName(room.members, room) + assert.equal(room.name, 'Alice, Chief A, Chief B, Writer A, Coder', 'the fixture starts with the guest in the name') + const guest = guestSide(sent) + + await rt.setGrants('spec-a', []) + await rt.evictUnshared('spec-a') + const joins = guest.reads() + assert.equal(room.name, 'Alice, Chief A, Chief B, Coder', 'the eviction moves the name here') + assert.notEqual(room.participants.find(p => p.id === 'spec-a')?.leftSeq, undefined, 'the Bot left the room') + assert.equal(guest.copies.get(CODER.handle), room.name, 'the guest still seated hears the name the host holds') + assert.equal(joins.get(CODER.handle), 1, 'one eviction costs a seated guest one join') + assert.deepEqual(titles.map(entry => entry.name), ['Alice, Chief A, Chief B, Coder'], 'and the Session follows once') +}) + +// #884/#1064: a link's claim seats a home's Bots in a room that already holds a guest. The seats are told +// by their own join; the guest that was already there owes the moved name, exactly once. +test('#2004: a claim that seats a Bot moves the name for the guest already in the room', async (t) => { + const { rt, sent } = titledHost(t, TEAM) + await rt.load() + publish(rt, [CODER]) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + const guest = guestSide(sent) + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: CODER.handle }, ALICE) + guest.reads() + const ticket = 'Tkt0001AbCdEfGhIjKlMnO' + room.localInvites = [{ ticket, server: true, botId: 'chief-a', createdAt: Date.now(), homes: {} }] + rt.roomInviteServer = { + verify: async () => ({ + roomId: 'war', + claimers: [{ + home: '@bob.home', + bots: ['@chief.bob.home'], + members: [{ handle: '@chief.bob.home', display_name: 'Chief', owner_name: 'Bob' }], + }], + }), + } + + const answered = await rt.applyInboundRoomShare({ + text: encodeRoomShare({ op: 'ask', accept: true, ticket, roomId: 'war', bots: ['@chief.bob.home'] }), + fromHandle: '@chief.bob.home', + fromHome: '@bob.home', + localBotId: 'chief-a', + }) + const joins = guest.reads() + assert.equal(answered?.accepted, true, 'the claim seats its Bot') + assert.equal(room.name, 'Alice, Chief A, Chief B, Coder, Chief', 'the claim moves the name here') + assert.equal(guest.copies.get(CODER.handle), room.name, 'the guest already in the room hears the name the host holds') + assert.equal(joins.get(CODER.handle), 1, 'one claim costs a seated guest one join') + assert.equal(guest.copies.get('@chief.bob.home'), room.name, 'the claimed seat is told the name it is seated under') + assert.equal(joins.get('@chief.bob.home'), 1, 'the claimed seat is told once, not twice') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 09266158a..68f9bef24 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -327,7 +327,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi ### Inbound shared rooms -- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a human participant whose name is this home's own label `You` (an owner with no name yet) is skipped for that owner-level name, so the far side never reads itself as the inviter (#883); a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden (#1167: the host sends that leave while the seat is still a live member, because reach and a room link's pairs only see current participants). The mirror then stays hidden: a later line is refused and a join that is not newer than that leave does not open it again; a newer join can. A hidden mirror sends nothing further. A projection is **named** from the envelope and **matched** by the transport (#873, #909): `applyInboundRoomShare` still shows `data.inviterName` (so a room carried here by a Chief's Bot reads after the person who invited it), but which room a share may touch is decided by `event.fromHome` / `event.fromHandle` — a share naming a room of mine while arriving from a home that room does not belong to is refused `not-inviter`, and the recorded `sharedFrom` identity only ever follows the transport, so an envelope can be displayed but never re-write whose room it is. Only `op: 'join` projects a room: a `line` envelope for an unknown remote room is refused `unknown-room` instead of opening one named by its own payload. A line keeps its own `who`, or the sender when it names nobody, never the inviter; its `authorId` follows the payload only when the handle traces to the transport sender, a remote seat of that room, or its inviter **from the home that invited the room** (#918 — the seat branch and the inviter branch now ask the same question) — otherwise the line is shown as the Bot that delivered it with `unverified: true`, and it is still kept so a forged claim cannot silence a message. The room match stays on the inviter handle alone, so a line presenting that handle from another home still lands and is only attributed to its sender: a room may match where a signature does not. Which room a share belongs to is recorded by a `join` alone; a `line` follows that record and never rewrites the inviter home or handle, or every message would make its sender the inviter. A room recorded before #873 may hold no inviter home at all, and then the only signature it keeps is the transport naming the sender itself (`claim === sender`); the next `join` records the home and the room stops being legacy. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode. #904: the projection now records the host's side too. `recordInboundSharedRoom` keeps the guest's own Bot **and** one remote participant for the Bot that carried the join (`draft.inviterHandle`, with `admittedBy: 'ticket'` / `roomOnly` when a link seated it) — `invitedHandle` on this side names one of the guest's own Bots, so it is never recorded as a remote, and the friend path keeps its one remote. The rest of the host's room rides the `join` as `roster` and `notice`: `notifyRoomShare` sends `{ handle, home, name, ownerName }` for every Bot of this home in the room, and `{ name, ownerName, connected: false }` for one with no Bot Social identity; nothing of another guest's home is published, and the roster is re-sent whenever the host adds or removes a Bot of its own. A notice the host edits or clears in the room's settings now pushes that same `join` too (`roster.js` `updateRoom`, after the save): a guest already seated holds the old notice and no seat change is coming, so the envelope carries the same roster snapshot with only the notice field changed, and a settings save that leaves the notice alone — or writes back the value the room already holds — sends nothing. A rename is that same push ([#1949](https://github.com/ThinkFlowLab/FlyBot/issues/1949)): `updateRoom` sends the one `join` when the name changed too, so a guest already seated holds the new name without waiting for the next line, and a save that changes the name and the notice together still sends one envelope. A member's rename moves an unnamed group's title the same way ([#1968](https://github.com/ThinkFlowLab/FlyBot/issues/1968)): `followMemberNames` recomputes the name after a Bot is renamed or removed and sends that one `join` too, because the guests already seated hold the name they were given at join and nothing else tells them; the guard that skips a room whose computed name did not move is what keeps it to real changes. A seat changing moves it as well ([#1995](https://github.com/ThinkFlowLab/FlyBot/issues/1987)): every path that seats or unseats somebody — `addRoomParticipant`, `removeRoomParticipant`, a room link's seats claimed into the room, a seat's own leave, and the grant eviction — ends in `settleRoomName`, which recomputes an unnamed room's name through the same wide formula and hands it to the room's own Session, so a guest who joins is in the title and a guest who leaves drops out of it instead of staying in `state.json`; a group made with a guest is opened before its seats are known, so `createRoom` retitles that Session once the participants are applied. A room with a chosen name, a mirror of another home's room (`inbound-share`) and a face-to-face room keep the title they have. The same save also leaves one line in the room itself ([#1962](https://github.com/ThinkFlowLab/FlyBot/issues/1962)): `updateRoom` posts a single quiet `face-notice` event naming who changed the notice (`updated the group notice` / `cleared the notice`), the room name (`renamed the room to “X”`), the reply mode, the admin or the task lead, so everyone already holding the room learns it without opening the settings. It is one line for the whole patch however many of these fields it changes, it opens no Bot turn, a seat change keeps its own joined/left line and draws no settings line, and a patch that writes back the values the room already holds — or an unnamed room's title following its members — says nothing. The name on that line is the signed-in member, else the Bot that acted through its own tool, else the room's implicit owner ([#1967](https://github.com/ThinkFlowLab/FlyBot/issues/1967)): the web UI keeps no member session, so without that step every save the local person makes would read `Someone`; only a room with no owner at all falls back to `the user`. `applyInboundRoomShare` reads a roster only when the envelope names this mirror's room (`data.roomId` = `sharedFrom.remoteRoomId`) and comes from its bound inviter, and merges it into the participants: one remote participant per entry (`fromRoster`, `roomOnly`), keyed by `(home, handle)` so two same-named Bots of different homes stay distinct, `verified` only for the seat or a published friend Bot. Verification is a signature, not a mention: an unverified entry is displayed and is not who a line may claim to be, an identity-less one is shown as not connected, and an @ of either — the title the @ menu inserts, or the roster name when it has no handle — still starts that Bot's turn on its own home (#949). A roster is the whole list, so a `join` that drops an entry marks it left (soft leave: the entry stays with `leftSeq`, history still resolves) and a re-sent `join` clears the mark; an empty `roster: []` clears the list, while a `join` without the field changes nothing. The notice is replaced by the next `join` that carries one; an explicit empty string clears it and a missing field leaves it. #923: the one pair a room link opened is enumerated even when the seat record predates it. A seat a link admitted is a pair through its own `admittedBy: 'ticket'` / `ticket`, and, when that record is older, through the link's own `homes` — the home-to-handles map the accept writes and keeps — reached from the Bot whose handle it knocked on, or from the room's lead when the seat did not even keep that. So a group envelope from this home reaches that guest as the room's traffic (`sendTicketShare` names the room the link was minted for) instead of falling back to a friendship the two homes never had; a friend's Bot added by hand is named by no link and stays out. This is FlyBot's half of the pair: Bot Social still decides at send time, so a room-started conversation it refuses needs the server-side fix. +- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a human participant whose name is this home's own label `You` (an owner with no name yet) is skipped for that owner-level name, so the far side never reads itself as the inviter (#883); a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden (#1167: the host sends that leave while the seat is still a live member, because reach and a room link's pairs only see current participants). The mirror then stays hidden: a later line is refused and a join that is not newer than that leave does not open it again; a newer join can. A hidden mirror sends nothing further. A projection is **named** from the envelope and **matched** by the transport (#873, #909): `applyInboundRoomShare` still shows `data.inviterName` (so a room carried here by a Chief's Bot reads after the person who invited it), but which room a share may touch is decided by `event.fromHome` / `event.fromHandle` — a share naming a room of mine while arriving from a home that room does not belong to is refused `not-inviter`, and the recorded `sharedFrom` identity only ever follows the transport, so an envelope can be displayed but never re-write whose room it is. Only `op: 'join` projects a room: a `line` envelope for an unknown remote room is refused `unknown-room` instead of opening one named by its own payload. A line keeps its own `who`, or the sender when it names nobody, never the inviter; its `authorId` follows the payload only when the handle traces to the transport sender, a remote seat of that room, or its inviter **from the home that invited the room** (#918 — the seat branch and the inviter branch now ask the same question) — otherwise the line is shown as the Bot that delivered it with `unverified: true`, and it is still kept so a forged claim cannot silence a message. The room match stays on the inviter handle alone, so a line presenting that handle from another home still lands and is only attributed to its sender: a room may match where a signature does not. Which room a share belongs to is recorded by a `join` alone; a `line` follows that record and never rewrites the inviter home or handle, or every message would make its sender the inviter. A room recorded before #873 may hold no inviter home at all, and then the only signature it keeps is the transport naming the sender itself (`claim === sender`); the next `join` records the home and the room stops being legacy. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode. #904: the projection now records the host's side too. `recordInboundSharedRoom` keeps the guest's own Bot **and** one remote participant for the Bot that carried the join (`draft.inviterHandle`, with `admittedBy: 'ticket'` / `roomOnly` when a link seated it) — `invitedHandle` on this side names one of the guest's own Bots, so it is never recorded as a remote, and the friend path keeps its one remote. The rest of the host's room rides the `join` as `roster` and `notice`: `notifyRoomShare` sends `{ handle, home, name, ownerName }` for every Bot of this home in the room, and `{ name, ownerName, connected: false }` for one with no Bot Social identity; nothing of another guest's home is published, and the roster is re-sent whenever the host adds or removes a Bot of its own. A notice the host edits or clears in the room's settings now pushes that same `join` too (`roster.js` `updateRoom`, after the save): a guest already seated holds the old notice and no seat change is coming, so the envelope carries the same roster snapshot with only the notice field changed, and a settings save that leaves the notice alone — or writes back the value the room already holds — sends nothing. A rename is that same push ([#1949](https://github.com/ThinkFlowLab/FlyBot/issues/1949)): `updateRoom` sends the one `join` when the name changed too, so a guest already seated holds the new name without waiting for the next line, and a save that changes the name and the notice together still sends one envelope. A member's rename moves an unnamed group's title the same way ([#1968](https://github.com/ThinkFlowLab/FlyBot/issues/1968)): `followMemberNames` recomputes the name after a Bot is renamed or removed and sends that one `join` too, because the guests already seated hold the name they were given at join and nothing else tells them; the guard that skips a room whose computed name did not move is what keeps it to real changes. A seat changing moves it as well ([#1995](https://github.com/ThinkFlowLab/FlyBot/issues/1987)): every path that seats or unseats somebody — `addRoomParticipant`, `removeRoomParticipant`, a room link's seats claimed into the room, a seat's own leave, and the grant eviction — ends in `settleRoomName`, which recomputes an unnamed room's name through the same wide formula and hands it to the room's own Session, so a guest who joins is in the title and a guest who leaves drops out of it instead of staying in `state.json`; a group made with a guest is opened before its seats are known, so `createRoom` retitles that Session once the participants are applied. The other homes learn it too ([#2004](https://github.com/ThinkFlowLab/FlyBot/issues/2004)): `settleRoomName` answers whether the name moved, and the path that moved it sends one `join` through `reshareRoster` to the guests already seated, so their copy of the name is the same text the host holds; the seat that just changed is left out of that broadcast, because its own targeted `join` is the one it is owed, and a room whose name did not move sends nothing. A room with a chosen name, a mirror of another home's room (`inbound-share`) and a face-to-face room keep the title they have. The same save also leaves one line in the room itself ([#1962](https://github.com/ThinkFlowLab/FlyBot/issues/1962)): `updateRoom` posts a single quiet `face-notice` event naming who changed the notice (`updated the group notice` / `cleared the notice`), the room name (`renamed the room to “X”`), the reply mode, the admin or the task lead, so everyone already holding the room learns it without opening the settings. It is one line for the whole patch however many of these fields it changes, it opens no Bot turn, a seat change keeps its own joined/left line and draws no settings line, and a patch that writes back the values the room already holds — or an unnamed room's title following its members — says nothing. The name on that line is the signed-in member, else the Bot that acted through its own tool, else the room's implicit owner ([#1967](https://github.com/ThinkFlowLab/FlyBot/issues/1967)): the web UI keeps no member session, so without that step every save the local person makes would read `Someone`; only a room with no owner at all falls back to `the user`. `applyInboundRoomShare` reads a roster only when the envelope names this mirror's room (`data.roomId` = `sharedFrom.remoteRoomId`) and comes from its bound inviter, and merges it into the participants: one remote participant per entry (`fromRoster`, `roomOnly`), keyed by `(home, handle)` so two same-named Bots of different homes stay distinct, `verified` only for the seat or a published friend Bot. Verification is a signature, not a mention: an unverified entry is displayed and is not who a line may claim to be, an identity-less one is shown as not connected, and an @ of either — the title the @ menu inserts, or the roster name when it has no handle — still starts that Bot's turn on its own home (#949). A roster is the whole list, so a `join` that drops an entry marks it left (soft leave: the entry stays with `leftSeq`, history still resolves) and a re-sent `join` clears the mark; an empty `roster: []` clears the list, while a `join` without the field changes nothing. The notice is replaced by the next `join` that carries one; an explicit empty string clears it and a missing field leaves it. #923: the one pair a room link opened is enumerated even when the seat record predates it. A seat a link admitted is a pair through its own `admittedBy: 'ticket'` / `ticket`, and, when that record is older, through the link's own `homes` — the home-to-handles map the accept writes and keeps — reached from the Bot whose handle it knocked on, or from the room's lead when the seat did not even keep that. So a group envelope from this home reaches that guest as the room's traffic (`sendTicketShare` names the room the link was minted for) instead of falling back to a friendship the two homes never had; a friend's Bot added by hand is named by no link and stays out. This is FlyBot's half of the pair: Bot Social still decides at send time, so a room-started conversation it refuses needs the server-side fix. - A Bot named in a shared room answers in that room (#825). `fanoutRoomLine` (`src/host/room-membership.js`) puts the calls a line makes in the envelope's `calls` (resolved where the room's mentions are resolved: the social handle, the roster name, and the title the @ menu inserts, so `@Chief (owner)` and `@Data (Friend)` name the same Bot a bare name would; a Bot with no social handle is called by that roster name, and a `not-connected:` id is never a token — #949). A fenced or quoted `@name` stays literal text. The envelope also carries the line's `hop`: a person's line is hop 0 and a Bot's answer is one hop on. The receiving `applyInboundRoomShare` keeps only the calls that name this room's own Bot (`localCalls`), opens **one** turn for each through `rt.startInboundTurn` (`groups.js`: `participant.open` + `participant.runTurn` + `appendRoom`), and skips it when the line's hop has reached the room's `maxHops` (kept on the room, `DEFAULT_MAX_HOPS` 3) or when the same remote line was already answered — the last 200 keys `remoteRoomId:seq:botId` live in `room.answered`. That turn's answer is an ordinary room line, so `fanoutRoomLine` sends it back to the inviter as an `op: 'line'` envelope under the *inviter's* `roomId`, one hop on; an answer its owner's approval holds or refuses still shows as one `notice` line in the room instead of silence. Remote text stays inside the `fencedUntrusted` fence. ### A local room's invite link (#852) diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index abbbaefa6..cb1ef5619 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -422,12 +422,15 @@ export function install(rt) { // await still saves the right one; only the Session rename is deferred, and only when the name moved. // A mirror of another home's room takes its name from that home's envelope and a face-to-face room has // its own title, so neither is recomputed here. + // The answer says whether the name moved. The other homes only ever heard a name on an envelope + // addressed to the seat that changed, so a guest already seated keeps the copy it arrived with while + // this home moves on: a caller that sees `true` owes those guests one broadcast (#2004). const settleRoomName = (room) => { - if (room.named || room.origin === 'inbound-share' || isFaceRoom(room)) return Promise.resolve() + if (room.named || room.origin === 'inbound-share' || isFaceRoom(room)) return Promise.resolve(false) const next = rt.autoRoomName(room.members, room) - if (next === room.name) return Promise.resolve() + if (next === room.name) return Promise.resolve(false) room.name = next - return rt.retitleRoom(room) + return Promise.resolve(rt.retitleRoom(room)).then(() => true) } const materialize = (room) => { @@ -749,10 +752,14 @@ export function install(rt) { } } syncMembers(room) - await settleRoomName(room) + const moved = await settleRoomName(room) await rt.save() - if (record?.remote && record.handle) void rt.notifyRoomShare?.(room, { op: 'join', handle: record.handle }) - else if (record?.kind === 'bot' && !record.remote) reshareRoster(room) + if (record?.remote && record.handle) { + void rt.notifyRoomShare?.(room, { op: 'join', handle: record.handle }) + // #2004: the arrival moved the name and only the arriving seat heard it; the guests already in + // the room get the same name once, and the arriving seat keeps its one targeted join. + if (moved) reshareRoster(room, { except: record.handle }) + } else if (record?.kind === 'bot' && !record.remote) reshareRoster(room) return room }) } @@ -800,7 +807,7 @@ export function install(rt) { rt.dropGroupSession?.(room, id) } syncMembers(room) - await settleRoomName(room) + const moved = await settleRoomName(room) await rt.save() if (me.remote && isTicketSeat(me)) { await settleTicket(room, me.ticket, me).catch(error => rt.warn?.('ds-bot: a seat\'s leave was not settled: %s', error)) @@ -809,6 +816,9 @@ export function install(rt) { } else if (kind === 'bot' && !me.remote) { reshareRoster(room) } + // #2004: the leave moved the name, so the guests still in the room get the copy the host holds. The + // seat that left is already out of `currentParticipants`; a local Bot's leave sent its roster above. + if (me.remote && moved) reshareRoster(room) return room }) } @@ -1038,10 +1048,12 @@ export function install(rt) { // #904: a guest's copy learns this home's membership only from a fresh roster. A local add or remove // therefore has to send one; without this the guest list never changes and a removed Bot keeps writing. - function reshareRoster(room) { + // #2004: `except` is the seat a targeted join already reached, so a name that moved together with that + // seat costs it one envelope and not two. + function reshareRoster(room, { except } = {}) { if (!room || room.origin === 'inbound-share' || isFaceRoom(room)) return if (!(room.participants ?? []).some(p => p.remote)) return - void Promise.resolve(rt.notifyRoomShare?.(room, { op: 'join' })) + void Promise.resolve(rt.notifyRoomShare?.(room, except === undefined ? { op: 'join' } : { op: 'join', except })) .catch(error => rt.warn?.('ds-bot: a membership change was not shared: %s', error)) } @@ -1298,11 +1310,15 @@ export function install(rt) { // Membership notice: the add/remove click is the owner's consent, so this does not wait // for a chat line. Bot Social is 1:1; the envelope is the group record the other FlyBot sees. - rt.notifyRoomShare = async function notifyRoomShare(room, { op = 'join', handle } = {}) { + // #2004: `except` names the seats a targeted envelope already reached, so one seat change never costs + // the seat it changed two joins: its own, and the one that carries the name to the others. + rt.notifyRoomShare = async function notifyRoomShare(room, { op = 'join', handle, except } = {}) { if (!room || isFaceRoom(room) || room.origin === 'inbound-share') return [] const wanted = handle ? String(handle) : undefined + const skipped = except === undefined ? undefined : new Set((Array.isArray(except) ? except : [except]).map(String)) const remotes = (op === 'leave' ? (room.participants ?? []) : currentParticipants(room)) .filter(p => isRemoteParticipant(p) && (!wanted || p.handle === wanted || p.id === wanted)) + .filter(p => !skipped || !(skipped.has(String(p.handle ?? p.id)) || skipped.has(String(p.id)))) if (remotes.length === 0) return [] const from = shareInviter(room) const results = [] @@ -1787,10 +1803,13 @@ export function install(rt) { }) } syncMembers(room) - await settleRoomName(room) + const moved = await settleRoomName(room) // A poll reads this again every few seconds: a claim that is already seated must not rewrite the team. if (fresh.length > 0 || legacy || homesBefore !== JSON.stringify(homesOf(invite))) await rt.save?.() - return { fresh } + // #2004: the seats this claim just made are told by their own join, sent by the caller; the guests + // that were in the room before it are the ones the moved name has to reach. + if (moved) reshareRoster(room, { except: seats }) + return { fresh, moved } }) if (result.refused) return { refused: result.refused, bots: result.bots } return { seats, fresh: result.fresh } @@ -1989,12 +2008,15 @@ export function install(rt) { // and the sender must be that seat's own Bot; anything else is dropped without a trace in the room. async function ingestSeatShare(room, seat, data) { if (data.op === 'leave') { + let moved = false await withRoomLock(async () => { markSeatLeft(room, seat) syncMembers(room) - await settleRoomName(room) + moved = await settleRoomName(room) }) await rt.save?.() + // #2004: the seat left and the name moved with it; the guests still seated hear the new one. + if (moved) reshareRoster(room) if (isTicketSeat(seat)) await settleTicket(room, seat.ticket, seat) return { handled: true, room, left: seat.handle } } @@ -2262,6 +2284,7 @@ export function install(rt) { if (!Array.isArray(room.participants) || !(hasRemote(room) || isCrossOwnerRoom(room, state))) continue for (const me of currentParticipants(room).filter(p => p.kind === 'bot' && !p.remote && (botId === undefined || p.id === botId))) { if (mayShareRoom(state, room, me.id)) continue + let moved = false await withRoomLock(async () => { if (me.leftSeq !== undefined) return const line = append(room, { kind: 'left', who: me.displayName, authorId: me.id, authorKind: 'bot', text: '' }) @@ -2270,9 +2293,11 @@ export function install(rt) { if (room.seen) delete room.seen[me.id] rt.dropGroupSession?.(room, me.id) syncMembers(room) - await settleRoomName(room) + moved = await settleRoomName(room) changed = true }) + // #2004: the eviction moved this room's name; the guests still seated hear it once. + if (moved) reshareRoster(room) } } if (changed) await rt.save() diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index 7de6635a3..2bd5b6116 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -3385,7 +3385,7 @@ test('a restart migrates an unnamed room title with the formula that sees the ro // no session machinery, so the two module-level paths are added here: a workspace registry, a Session // controller and a title recorder. function titledHost(t, saved) { - const { rt } = host(t, saved) + const { rt, sent } = host(t, saved) const titles = [] const workspaces = new Map() let sessions = 0 @@ -3406,7 +3406,7 @@ function titledHost(t, saved) { }) installRoster(rt) rt.resolveAgent = async id => ({ session: { id } }) - return { rt, titles } + return { rt, titles, sent } } // The one owner of TEAM; authz is not enforced until a member session exists, so a plain actor is enough @@ -3511,3 +3511,210 @@ test('#1995: an unnamed group without guests is renamed once per seat change', a assert.equal(titles.length, 1, 'one seat change is one rename, not a second from the settings save') assert.deepEqual(titles.map(entry => entry.name), ['Alice, Chief A, Chief B, Writer A']) }) + +// #2004: #1999 moved an unnamed group's name with its seats here, but the name only ever left this home +// on an envelope addressed to the seat that changed; a guest already seated kept the copy its last +// envelope carried. These cases read the other home's side the way `applyInboundRoomShare` does: an +// envelope addressed to a seat replaces that seat's copy of `name`, and a `join` is what a seat counts. +const WRITER = { + handle: '@writer.cy.home', + name: 'Writer', + home: '@cy.home', + homeName: 'Cy', + owner: 'Cy', + plugin: 'social', + remote: true, +} + +function guestSide(sent) { + const copies = new Map() + let cursor = 0 + return { + copies, + // The joins each seat received since the last read, and the name its copy ends on. + reads() { + const joins = new Map() + for (const msg of sent.slice(cursor)) { + const data = parseRoomShare(msg?.text) + if (!data || !msg.to) continue + if (typeof data.name === 'string') copies.set(msg.to, data.name) + if (data.op === 'join') joins.set(msg.to, (joins.get(msg.to) ?? 0) + 1) + } + cursor = sent.length + return joins + }, + } +} + +test('#2004: a seat change that moves the name reaches the guest already seated, once', async (t) => { + const { rt, titles, sent } = titledHost(t, TEAM) + await rt.load() + publish(rt, [CODER, WRITER]) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + const guest = guestSide(sent) + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: CODER.handle }, ALICE) + let joins = guest.reads() + assert.equal(guest.copies.get(CODER.handle), room.name, 'the arriving guest is told the name it arrives under') + assert.equal(joins.get(CODER.handle), 1, 'an arriving seat is told once, not twice') + + // A second guest sits: the guest already in the room has to end on the name the host holds, and one + // change may cost it one join — the arriving seat keeps its own targeted join and gets no second one. + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: WRITER.handle }, ALICE) + joins = guest.reads() + assert.equal(room.name, 'Alice, Chief A, Chief B, Coder, Writer') + assert.equal(guest.copies.get(CODER.handle), room.name, 'the guest already seated holds the name the host moved to') + assert.equal(joins.get(CODER.handle), 1, 'one seat change costs a seated guest one join') + assert.equal(guest.copies.get(WRITER.handle), room.name, 'the arriving guest is told the name it arrives under') + assert.equal(joins.get(WRITER.handle), 1, 'the arriving seat is told once, not twice') + + // The first guest leaves: the guest still seated has to hear whatever the name became. + await rt.removeRoomParticipant('war', { kind: 'bot', handle: CODER.handle }, ALICE) + joins = guest.reads() + assert.equal(room.name, 'Alice, Chief A, Chief B, Writer') + assert.equal(guest.copies.get(WRITER.handle), room.name, 'the guest that stayed holds the name the host holds after a leave') + assert.equal(joins.get(WRITER.handle), 1, 'one leave costs a seated guest one join') + + // And it sits again: the guest that stayed hears the name grow, the returning seat hears it once. + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: CODER.handle }, ALICE) + joins = guest.reads() + assert.equal(room.name, 'Alice, Chief A, Chief B, Writer, Coder') + assert.equal(guest.copies.get(WRITER.handle), room.name, 'the returning seat leaves the guest that stayed on the host name') + assert.equal(joins.get(WRITER.handle), 1, 'one return costs a seated guest one join') + assert.equal(guest.copies.get(CODER.handle), room.name, 'the returning guest is told the name it arrives under') + assert.equal(joins.get(CODER.handle), 1, 'the returning seat is told once, not twice') + assert.equal(titles.length, 4, 'the Session followed each of the four seat changes, no more') +}) + +test('#2004: a named group sends its seated guests nothing for a seat change', async (t) => { + const { rt, titles, sent } = titledHost(t, TEAM) + await rt.load() + publish(rt, [CODER, WRITER]) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + const guest = guestSide(sent) + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: CODER.handle }, ALICE) + let joins = guest.reads() + assert.equal(joins.get(CODER.handle), 1, 'the arriving seat gets the one join it is owed') + assert.equal(guest.copies.get(CODER.handle), 'War room') + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: WRITER.handle }, ALICE) + joins = guest.reads() + assert.equal(room.name, 'War room', 'a chosen name does not follow the seats') + assert.equal(joins.get(CODER.handle) ?? 0, 0, 'a name that did not move is not resent to a seated guest') + assert.equal(joins.get(WRITER.handle), 1, 'the arriving seat is told once') + + await rt.removeRoomParticipant('war', { kind: 'bot', handle: CODER.handle }, ALICE) + joins = guest.reads() + assert.equal(room.name, 'War room') + assert.equal(joins.get(WRITER.handle) ?? 0, 0, 'a leave that did not move the name sends nothing either') + assert.equal(titles.length, 0, 'and no Session is renamed for a chosen name') +}) + +test('#2004: a seat that leaves by its own envelope moves the name for the guests still seated', async (t) => { + const { rt, sent } = titledHost(t, TEAM) + await rt.load() + publish(rt, [CODER, WRITER]) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + const guest = guestSide(sent) + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: CODER.handle }, ALICE) + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: WRITER.handle }, ALICE) + guest.reads() + + // The guest's own home says it left; this home never sends that leave itself. + await rt.applyInboundRoomShare({ + text: encodeRoomShare({ op: 'leave', roomId: 'war', name: room.name, invitedHandle: CODER.handle, seq: room.seq ?? 0 }), + fromHandle: CODER.handle, + fromHome: '@bob.home', + localBotId: 'chief-a', + }) + const joins = guest.reads() + assert.equal(room.name, 'Alice, Chief A, Chief B, Writer', 'the leave moves the name here') + assert.equal(rt.state.rooms.war.participants.find(p => p.handle === CODER.handle)?.leftSeq !== undefined, true, 'the seat is gone from the roster') + assert.equal(guest.copies.get(WRITER.handle), room.name, 'the guest still seated hears the name the host holds') + assert.equal(joins.get(WRITER.handle), 1, 'one leave costs a seated guest one join') +}) + +test('#2004: a grant revoked under a guest moves the name for the guests still seated', async (t) => { + const { rt, titles, sent } = titledHost(t, TEAM) + await rt.load() + publish(rt, [CODER]) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + delete room.named + await grantToFriend(rt, 'spec-a') + rt.applyParticipants(room, [ + { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice' }, + { kind: 'bot', id: 'chief-a', role: 'admin' }, + { kind: 'bot', id: 'chief-b', role: 'member' }, + { kind: 'bot', id: 'spec-a', role: 'member' }, + { kind: 'bot', remote: true, handle: CODER.handle }, + ], ALICE) + room.name = rt.autoRoomName(room.members, room) + assert.equal(room.name, 'Alice, Chief A, Chief B, Writer A, Coder', 'the fixture starts with the guest in the name') + const guest = guestSide(sent) + + await rt.setGrants('spec-a', []) + await rt.evictUnshared('spec-a') + const joins = guest.reads() + assert.equal(room.name, 'Alice, Chief A, Chief B, Coder', 'the eviction moves the name here') + assert.notEqual(room.participants.find(p => p.id === 'spec-a')?.leftSeq, undefined, 'the Bot left the room') + assert.equal(guest.copies.get(CODER.handle), room.name, 'the guest still seated hears the name the host holds') + assert.equal(joins.get(CODER.handle), 1, 'one eviction costs a seated guest one join') + assert.deepEqual(titles.map(entry => entry.name), ['Alice, Chief A, Chief B, Coder'], 'and the Session follows once') +}) + +// #884/#1064: a link's claim seats a home's Bots in a room that already holds a guest. The seats are told +// by their own join; the guest that was already there owes the moved name, exactly once. +test('#2004: a claim that seats a Bot moves the name for the guest already in the room', async (t) => { + const { rt, sent } = titledHost(t, TEAM) + await rt.load() + publish(rt, [CODER]) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + const guest = guestSide(sent) + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: CODER.handle }, ALICE) + guest.reads() + const ticket = 'Tkt0001AbCdEfGhIjKlMnO' + room.localInvites = [{ ticket, server: true, botId: 'chief-a', createdAt: Date.now(), homes: {} }] + rt.roomInviteServer = { + verify: async () => ({ + roomId: 'war', + claimers: [{ + home: '@bob.home', + bots: ['@chief.bob.home'], + members: [{ handle: '@chief.bob.home', display_name: 'Chief', owner_name: 'Bob' }], + }], + }), + } + + const answered = await rt.applyInboundRoomShare({ + text: encodeRoomShare({ op: 'ask', accept: true, ticket, roomId: 'war', bots: ['@chief.bob.home'] }), + fromHandle: '@chief.bob.home', + fromHome: '@bob.home', + localBotId: 'chief-a', + }) + const joins = guest.reads() + assert.equal(answered?.accepted, true, 'the claim seats its Bot') + assert.equal(room.name, 'Alice, Chief A, Chief B, Coder, Chief', 'the claim moves the name here') + assert.equal(guest.copies.get(CODER.handle), room.name, 'the guest already in the room hears the name the host holds') + assert.equal(joins.get(CODER.handle), 1, 'one claim costs a seated guest one join') + assert.equal(guest.copies.get('@chief.bob.home'), room.name, 'the claimed seat is told the name it is seated under') + assert.equal(joins.get('@chief.bob.home'), 1, 'the claimed seat is told once, not twice') +})