diff --git a/changelog.d/1995-unnamed-title-follows-seats.fixed.md b/changelog.d/1995-unnamed-title-follows-seats.fixed.md new file mode 100644 index 000000000..88cd81458 --- /dev/null +++ b/changelog.d/1995-unnamed-title-follows-seats.fixed.md @@ -0,0 +1 @@ +The title of a group without a chosen name follows its seats now ([#1995](https://github.com/ThinkFlowLab/FlyBot/issues/1995)): a friend's Bot invited to such a group appears in its name and in the group's Session title the moment the seat exists, and a seat that leaves drops out of both instead of staying in `state.json` until a restart. Before this, only a settings save or a renamed Bot recomputed that name, so inviting or removing a guest left the title reading the old set of people while the roster had already changed. A group created together with a guest was opened under the narrow name — `You, Chief A, Chief B`, with the guest missing — and was corrected in `state.json` without ever reaching the Session the user reads. A group with a chosen name keeps it. diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 29bbe0dda..abbbaefa6 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -414,6 +414,22 @@ export function install(rt) { else if (room.admin && !room.members.includes(room.admin)) room.admin = room.members[0] ?? null } + // #1987: a group without a chosen name is named after its seats, so a seat that joins or leaves moves + // the name with it — and the group's own Session, the title the user reads, moves with the name. `room` + // is passed for both the comparison and the write: `autoRoomName` reads the people and the remote guests + // from it, so a call without one names the owner `You` and drops the guests, and that name would never + // match again (#1976). The name is set before the returned promise is awaited, so a path that cannot + // 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. + const settleRoomName = (room) => { + if (room.named || room.origin === 'inbound-share' || isFaceRoom(room)) return Promise.resolve() + const next = rt.autoRoomName(room.members, room) + if (next === room.name) return Promise.resolve() + room.name = next + return rt.retitleRoom(room) + } + const materialize = (room) => { if (!Array.isArray(room.participants)) room.participants = deriveParticipants(room, state) return room @@ -733,6 +749,7 @@ export function install(rt) { } } syncMembers(room) + 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) @@ -783,6 +800,7 @@ export function install(rt) { rt.dropGroupSession?.(room, id) } syncMembers(room) + 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)) @@ -1769,6 +1787,7 @@ export function install(rt) { }) } syncMembers(room) + 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 } @@ -1973,6 +1992,7 @@ export function install(rt) { await withRoomLock(async () => { markSeatLeft(room, seat) syncMembers(room) + await settleRoomName(room) }) await rt.save?.() if (isTicketSeat(seat)) await settleTicket(room, seat.ticket, seat) @@ -2250,6 +2270,7 @@ export function install(rt) { if (room.seen) delete room.seen[me.id] rt.dropGroupSession?.(room, me.id) syncMembers(room) + await settleRoomName(room) changed = true }) } diff --git a/core/src/host/roster.js b/core/src/host/roster.js index 51c009b56..326bebc20 100644 --- a/core/src/host/roster.js +++ b/core/src/host/roster.js @@ -349,6 +349,10 @@ export function install(rt) { rt.applyParticipants(state.rooms[id], drafts, actor) if (!custom) { state.rooms[id].name = autoRoomName(state.rooms[id].members, state.rooms[id]) + // #1987: the Session was opened with the narrow name — the seats were not known yet, so a group + // made with a guest read `You, …` and no guest. The name above is the one that sees the room, so + // the Session takes it; the comparison is what keeps a room without guests at one rename. + if (state.rooms[id].name !== title) await rt.retitleRoom(state.rooms[id]) } } await rt.save() diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index d15467bca..7de6635a3 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -3304,9 +3304,10 @@ test('a rename that leaves the computed name alone sends nothing', async (t) => await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) sent.length = 0 + const before = room.name await rt.updateBot('spec-a', { name: 'Writer C' }) - assert.equal(room.name, 'Alice, Chief A, Chief B', 'a Bot outside the group does not rename it') + assert.equal(room.name, before, 'a Bot outside the group does not rename it') assert.equal(sent.filter(msg => msg.via === 'room-share').length, 0, 'the computed name did not move') }) @@ -3330,9 +3331,10 @@ test('a restart migrates an unnamed room title with the formula that sees the ro delete room.named room.name = rt.autoRoomName(room.members, room) assert.equal(room.name, 'Alice, Chief A, Chief B') - // The join path keeps the title it had; out of scope here and the reason the stored name is now stale. await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) - assert.equal(room.name, 'Alice, Chief A, Chief B', 'a join does not recompute the title') + // #1976's subject is the boot migration, not whichever path left the stored name stale, so the stale + // name is written here: a file from before a join recomputed the title (#1995 does now). + room.name = 'Alice, Chief A, Chief B' await rt.save() const saved = readFileSync(rt.statePath, 'utf8') @@ -3378,3 +3380,134 @@ test('a restart migrates an unnamed room title with the formula that sees the ro await fifth.rt.load() assert.equal(readFileSync(fifth.rt.statePath, 'utf8'), healed, 'the healed file is left alone on the next boot') }) +// #1995: an unnamed group is named after its seats, so a seat that joins or leaves has to move the name +// with it — and the group's own Session, the title the user reads in the sidebar. `host()` above carries +// 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 titles = [] + const workspaces = new Map() + let sessions = 0 + rt.config = {} + Object.assign(rt.ctx, { + workspaceRegistry: { + get: id => workspaces.get(id), + resolveByPath: async path => [...workspaces.values()].find(workspace => workspace.path === path), + create: async (path) => { + const workspace = { id: `w${workspaces.size + 1}`, path } + workspaces.set(workspace.id, workspace) + return workspace + }, + archiveSession: async () => {}, + }, + sessionController: { create: async () => ({ sessionId: `s${++sessions}` }) }, + sessionTitle: { rename: async (session, name) => { titles.push({ id: session?.id, name }) } }, + }) + installRoster(rt) + rt.resolveAgent = async id => ({ session: { id } }) + return { rt, titles } +} + +// The one owner of TEAM; authz is not enforced until a member session exists, so a plain actor is enough +// for the roster paths these cases drive. +const ALICE = { memberId: 'owner-1', displayName: 'Alice' } + +test('#1995: a guest who joins an unnamed group moves the name and the Session title with it', async (t) => { + const { rt, titles } = titledHost(t, TEAM) + await rt.load() + publish(rt) + 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) + titles.length = 0 + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, ALICE) + + assert.equal(room.name, 'Alice, Chief A, Chief B, Coder', 'the guest is in the name the moment the seat exists') + assert.deepEqual(titles.map(entry => entry.name), ['Alice, Chief A, Chief B, Coder'], 'the group Session follows, once') +}) + +test('#1995: a guest who leaves an unnamed group drops out of its name and title', async (t) => { + const { rt, titles } = titledHost(t, TEAM) + await rt.load() + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + delete room.named + // The room as the join leaves it: the guest is seated and already in the name. The leave is what this + // case tests, so the invite path above is not needed to set it up. + 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', remote: true, handle: '@coder.bob.home' }, + ], ALICE) + room.name = rt.autoRoomName(room.members, room) + assert.equal(room.name, 'Alice, Chief A, Chief B, Coder', 'the fixture starts from a name that names the guest') + titles.length = 0 + + await rt.removeRoomParticipant('war', { kind: 'bot', handle: '@coder.bob.home' }, ALICE) + + assert.equal(room.name, 'Alice, Chief A, Chief B', 'the seat is gone from the name, not only from the roster') + assert.deepEqual(titles.map(entry => entry.name), ['Alice, Chief A, Chief B'], 'and the group Session lets it go, once') +}) + +test('#1995: a group created with a guest is titled by the formula that sees the room', async (t) => { + const { rt, titles } = titledHost(t, TEAM) + await rt.load() + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + + const room = await rt.createRoom({ + members: ['chief-a', 'chief-b'], + participants: [ + { kind: 'bot', id: 'chief-a', role: 'admin' }, + { kind: 'bot', id: 'chief-b' }, + { kind: 'bot', remote: true, handle: '@coder.bob.home' }, + ], + actor: ALICE, + }) + + assert.equal(room.name, 'Alice, Chief A, Chief B, Coder', 'the new group is named with the guest in it') + // The Session has to exist before the seats do, so it is opened under the narrow name and corrected + // once the room knows its guests; what the user is left with is the name that sees the room. + assert.equal(titles.filter(entry => entry.id === room.id).at(-1)?.name, 'Alice, Chief A, Chief B, Coder', + 'the Session ends on the name that sees the guest') +}) + +test('#1995: a named group keeps the name its owner chose, and no title is sent for it', async (t) => { + const { rt, titles } = titledHost(t, TEAM) + await rt.load() + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + assert.equal(room.named, true, 'the fixture room carries a chosen name') + titles.length = 0 + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, ALICE) + await rt.removeRoomParticipant('war', { kind: 'bot', handle: '@coder.bob.home' }, ALICE) + + assert.equal(room.name, 'War room', 'a chosen name does not follow the seats') + assert.deepEqual(titles, [], 'and the Session is not renamed for a seat change') +}) + +test('#1995: an unnamed group without guests is renamed once per seat change', async (t) => { + const { rt, titles } = titledHost(t, TEAM) + await rt.load() + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + titles.length = 0 + + await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, ALICE) + + assert.equal(room.name, 'Alice, Chief A, Chief B, Writer A', 'the added member is in the name') + 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']) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 1b8810d2c..c38657b1a 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -326,7 +326,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. 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. 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/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 7a7178014..fd03b7c8d 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -464,7 +464,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js const custom = cleanRoomName(roomName ?? '') // A Main Bot leads the groups it makes; a group the user makes is led by a Main // Bot member when it has one. -@@ -294,7 +294,19 @@ export function install(rt) { +@@ -294,7 +294,23 @@ export function install(rt) { createdAt: Date.now(), createdBy: createdBy ?? null, } @@ -477,6 +477,10 @@ diff --git a/src/host/roster.js b/src/host/roster.js + rt.applyParticipants(state.rooms[id], drafts, actor) + if (!custom) { + state.rooms[id].name = autoRoomName(state.rooms[id].members, state.rooms[id]) ++ // #1987: the Session was opened with the narrow name — the seats were not known yet, so a group ++ // made with a guest read `You, …` and no guest. The name above is the one that sees the room, so ++ // the Session takes it; the comparison is what keeps a room without guests at one rename. ++ if (state.rooms[id].name !== title) await rt.retitleRoom(state.rooms[id]) + } + } await rt.save() diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 29bbe0dda..abbbaefa6 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -414,6 +414,22 @@ export function install(rt) { else if (room.admin && !room.members.includes(room.admin)) room.admin = room.members[0] ?? null } + // #1987: a group without a chosen name is named after its seats, so a seat that joins or leaves moves + // the name with it — and the group's own Session, the title the user reads, moves with the name. `room` + // is passed for both the comparison and the write: `autoRoomName` reads the people and the remote guests + // from it, so a call without one names the owner `You` and drops the guests, and that name would never + // match again (#1976). The name is set before the returned promise is awaited, so a path that cannot + // 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. + const settleRoomName = (room) => { + if (room.named || room.origin === 'inbound-share' || isFaceRoom(room)) return Promise.resolve() + const next = rt.autoRoomName(room.members, room) + if (next === room.name) return Promise.resolve() + room.name = next + return rt.retitleRoom(room) + } + const materialize = (room) => { if (!Array.isArray(room.participants)) room.participants = deriveParticipants(room, state) return room @@ -733,6 +749,7 @@ export function install(rt) { } } syncMembers(room) + 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) @@ -783,6 +800,7 @@ export function install(rt) { rt.dropGroupSession?.(room, id) } syncMembers(room) + 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)) @@ -1769,6 +1787,7 @@ export function install(rt) { }) } syncMembers(room) + 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 } @@ -1973,6 +1992,7 @@ export function install(rt) { await withRoomLock(async () => { markSeatLeft(room, seat) syncMembers(room) + await settleRoomName(room) }) await rt.save?.() if (isTicketSeat(seat)) await settleTicket(room, seat.ticket, seat) @@ -2250,6 +2270,7 @@ export function install(rt) { if (room.seen) delete room.seen[me.id] rt.dropGroupSession?.(room, me.id) syncMembers(room) + await settleRoomName(room) changed = true }) } diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index d15467bca..7de6635a3 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -3304,9 +3304,10 @@ test('a rename that leaves the computed name alone sends nothing', async (t) => await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) sent.length = 0 + const before = room.name await rt.updateBot('spec-a', { name: 'Writer C' }) - assert.equal(room.name, 'Alice, Chief A, Chief B', 'a Bot outside the group does not rename it') + assert.equal(room.name, before, 'a Bot outside the group does not rename it') assert.equal(sent.filter(msg => msg.via === 'room-share').length, 0, 'the computed name did not move') }) @@ -3330,9 +3331,10 @@ test('a restart migrates an unnamed room title with the formula that sees the ro delete room.named room.name = rt.autoRoomName(room.members, room) assert.equal(room.name, 'Alice, Chief A, Chief B') - // The join path keeps the title it had; out of scope here and the reason the stored name is now stale. await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) - assert.equal(room.name, 'Alice, Chief A, Chief B', 'a join does not recompute the title') + // #1976's subject is the boot migration, not whichever path left the stored name stale, so the stale + // name is written here: a file from before a join recomputed the title (#1995 does now). + room.name = 'Alice, Chief A, Chief B' await rt.save() const saved = readFileSync(rt.statePath, 'utf8') @@ -3378,3 +3380,134 @@ test('a restart migrates an unnamed room title with the formula that sees the ro await fifth.rt.load() assert.equal(readFileSync(fifth.rt.statePath, 'utf8'), healed, 'the healed file is left alone on the next boot') }) +// #1995: an unnamed group is named after its seats, so a seat that joins or leaves has to move the name +// with it — and the group's own Session, the title the user reads in the sidebar. `host()` above carries +// 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 titles = [] + const workspaces = new Map() + let sessions = 0 + rt.config = {} + Object.assign(rt.ctx, { + workspaceRegistry: { + get: id => workspaces.get(id), + resolveByPath: async path => [...workspaces.values()].find(workspace => workspace.path === path), + create: async (path) => { + const workspace = { id: `w${workspaces.size + 1}`, path } + workspaces.set(workspace.id, workspace) + return workspace + }, + archiveSession: async () => {}, + }, + sessionController: { create: async () => ({ sessionId: `s${++sessions}` }) }, + sessionTitle: { rename: async (session, name) => { titles.push({ id: session?.id, name }) } }, + }) + installRoster(rt) + rt.resolveAgent = async id => ({ session: { id } }) + return { rt, titles } +} + +// The one owner of TEAM; authz is not enforced until a member session exists, so a plain actor is enough +// for the roster paths these cases drive. +const ALICE = { memberId: 'owner-1', displayName: 'Alice' } + +test('#1995: a guest who joins an unnamed group moves the name and the Session title with it', async (t) => { + const { rt, titles } = titledHost(t, TEAM) + await rt.load() + publish(rt) + 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) + titles.length = 0 + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, ALICE) + + assert.equal(room.name, 'Alice, Chief A, Chief B, Coder', 'the guest is in the name the moment the seat exists') + assert.deepEqual(titles.map(entry => entry.name), ['Alice, Chief A, Chief B, Coder'], 'the group Session follows, once') +}) + +test('#1995: a guest who leaves an unnamed group drops out of its name and title', async (t) => { + const { rt, titles } = titledHost(t, TEAM) + await rt.load() + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + delete room.named + // The room as the join leaves it: the guest is seated and already in the name. The leave is what this + // case tests, so the invite path above is not needed to set it up. + 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', remote: true, handle: '@coder.bob.home' }, + ], ALICE) + room.name = rt.autoRoomName(room.members, room) + assert.equal(room.name, 'Alice, Chief A, Chief B, Coder', 'the fixture starts from a name that names the guest') + titles.length = 0 + + await rt.removeRoomParticipant('war', { kind: 'bot', handle: '@coder.bob.home' }, ALICE) + + assert.equal(room.name, 'Alice, Chief A, Chief B', 'the seat is gone from the name, not only from the roster') + assert.deepEqual(titles.map(entry => entry.name), ['Alice, Chief A, Chief B'], 'and the group Session lets it go, once') +}) + +test('#1995: a group created with a guest is titled by the formula that sees the room', async (t) => { + const { rt, titles } = titledHost(t, TEAM) + await rt.load() + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + + const room = await rt.createRoom({ + members: ['chief-a', 'chief-b'], + participants: [ + { kind: 'bot', id: 'chief-a', role: 'admin' }, + { kind: 'bot', id: 'chief-b' }, + { kind: 'bot', remote: true, handle: '@coder.bob.home' }, + ], + actor: ALICE, + }) + + assert.equal(room.name, 'Alice, Chief A, Chief B, Coder', 'the new group is named with the guest in it') + // The Session has to exist before the seats do, so it is opened under the narrow name and corrected + // once the room knows its guests; what the user is left with is the name that sees the room. + assert.equal(titles.filter(entry => entry.id === room.id).at(-1)?.name, 'Alice, Chief A, Chief B, Coder', + 'the Session ends on the name that sees the guest') +}) + +test('#1995: a named group keeps the name its owner chose, and no title is sent for it', async (t) => { + const { rt, titles } = titledHost(t, TEAM) + await rt.load() + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + const room = rt.state.rooms.war + assert.equal(room.named, true, 'the fixture room carries a chosen name') + titles.length = 0 + + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, ALICE) + await rt.removeRoomParticipant('war', { kind: 'bot', handle: '@coder.bob.home' }, ALICE) + + assert.equal(room.name, 'War room', 'a chosen name does not follow the seats') + assert.deepEqual(titles, [], 'and the Session is not renamed for a seat change') +}) + +test('#1995: an unnamed group without guests is renamed once per seat change', async (t) => { + const { rt, titles } = titledHost(t, TEAM) + await rt.load() + const room = rt.state.rooms.war + delete room.named + room.name = rt.autoRoomName(room.members, room) + titles.length = 0 + + await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, ALICE) + + assert.equal(room.name, 'Alice, Chief A, Chief B, Writer A', 'the added member is in the name') + 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']) +})