From 5d69e268958d2c8cdb1ee27c71b06a11886c8bf7 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:49:39 +0800 Subject: [PATCH] host: the settings line names the local user, not Someone (#1967) showSettings fell back to 'Someone' when neither a signed-in actor nor a Bot id reached it, which is the only path the web UI takes: nothing writes its member-session key. It now takes the room's implicit owner, then the host's wording for the local person, after the actor and the Bot that already win. core-host.md records the order, and one case drives the unsigned-in save through the real updateRoom route. --- .../1967-settings-line-attribution.fixed.md | 1 + core/src/host/roster.js | 8 +++++++- core/test/remote-room.test.js | 19 +++++++++++++++++++ docs/subsystems/core-host.md | 2 +- patches/ds-bot/0002-flybot-api-host.patch | 12 +++++++++--- patches/ds-bot/files/test/remote-room.test.js | 19 +++++++++++++++++++ 6 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 changelog.d/1967-settings-line-attribution.fixed.md diff --git a/changelog.d/1967-settings-line-attribution.fixed.md b/changelog.d/1967-settings-line-attribution.fixed.md new file mode 100644 index 000000000..028147636 --- /dev/null +++ b/changelog.d/1967-settings-line-attribution.fixed.md @@ -0,0 +1 @@ +The one line a group-settings save leaves in the room now names the person who saved it on the default single-user setup ([#1967](https://github.com/ThinkFlowLab/FlyBot/issues/1967)): the web UI has no member session, so the save reached the host with no name and every rename, notice, mode or admin change read "Someone updated the group notice". The line now takes the room's implicit owner — the local person whose Bots sit in it — while a signed-in member and a Bot saving through its own tool still win, and only a room with no owner at all falls back to "the user". diff --git a/core/src/host/roster.js b/core/src/host/roster.js index 902782aad..51c009b56 100644 --- a/core/src/host/roster.js +++ b/core/src/host/roster.js @@ -6,6 +6,7 @@ import { MAX_AVATAR_IMAGE, ROOM_MAX_MEMBERS, ROOM_MODES, ROOM_NAME_MAX, ROOM_NOT import { currentColor, cut, isColor, unquote } from './text.js' import { nameProblem } from '../shared/mentions.js' import { cleanExternal } from './room-participant-external.js' +import { implicitRoomOwner } from './room-membership.js' import { ownEntry } from './store.js' export function install(rt) { @@ -501,7 +502,12 @@ export function install(rt) { } function showSettings(room, settings, actor, actorId) { - const who = actor?.displayName ?? rt.botOf(actorId)?.name ?? 'Someone' + // The web UI has no member session (#1967), so the local user's save arrives with neither an actor nor a + // Bot id: the room's implicit owner names it, the way the room line already does. Only a room with no + // owner at all falls back to the host's own wording for the local person; a signed-in member and a Bot + // acting through its tool still win, as they did. + const who = actor?.displayName ?? rt.botOf(actorId)?.name + ?? implicitRoomOwner(room, state)?.displayName ?? 'the user' const said = settings.length === 1 ? settings[0] : `${settings.slice(0, -1).join(', ')} and ${settings.at(-1)}` try { void Promise.resolve(rt.postToRoom?.(room, { id: 'face-notice', name: who }, `${who} ${said}`, { quiet: true, notice: true })) diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index b136cd255..f1f415681 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -2567,6 +2567,25 @@ test('writing back the reply mode the room already holds says nothing', async (t assert.deepEqual(events, [], 'a save that writes the same mode back says nothing') }) +// #1967: the local user has no member session (nothing in this repository writes the client's +// `flybot-member-session` key), so a save from the web UI reaches the roster with neither an actor nor a +// Bot id. The line that says who changed what must still name the room's owner, and never read as a +// third party doing it. +test('a settings save with no session names the room owner, not Someone', async (t) => { + const { rt } = host(t, TEAM) + installRoster(rt) + await rt.load() + const events = [] + rt.postToRoom = async (room, sender, text, extra) => { events.push({ room: room.id, sender, text, extra }) } + + await rt.updateRoom('war', { notice: 'Stand up' }) + + assert.equal(events.length, 1, `one save is one line: ${JSON.stringify(events)}`) + assert.equal(events[0].text, 'Alice updated the group notice', 'the local user is named, not someone else') + assert.ok(!events[0].text.includes('Someone'), 'a save by the local user never reads as a third party') + assert.deepEqual(events[0].extra, { quiet: true, notice: true }, 'still quiet and a notice, so no turn opens') +}) + // #904: a roster is stranger-written text, so the envelope that carries one is gated: only the room's // own inviter, only the room this mirror is bound to, and nothing it says widens reach by itself. test('#904: a roster from another home or for another room changes no participant', async (t) => { diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index a0a63c101..6a4c7bf21 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. `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. 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 e49930bce..79d10f9f8 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -154,7 +154,7 @@ diff --git a/src/host/home-lock.js b/src/host/home-lock.js diff --git a/src/host/roster.js b/src/host/roster.js --- a/src/host/roster.js +++ b/src/host/roster.js -@@ -3,8 +3,10 @@ +@@ -3,8 +3,11 @@ import { mkdir } from 'node:fs/promises' import { join } from 'node:path' import { MAX_AVATAR_IMAGE, ROOM_MAX_MEMBERS, ROOM_MODES, ROOM_NAME_MAX, ROOM_NOTICE_MAX } from './constants.js' @@ -162,6 +162,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js +import { currentColor, cut, isColor, unquote } from './text.js' import { nameProblem } from '../shared/mentions.js' +import { cleanExternal } from './room-participant-external.js' ++import { implicitRoomOwner } from './room-membership.js' +import { ownEntry } from './store.js' export function install(rt) { @@ -590,7 +591,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js } // Members' group conversations move on to the folder at their next turn there. if (moving && workDir !== room.workDir) { -@@ -371,20 +371,53 @@ export function install(rt) { +@@ -371,20 +371,58 @@ export function install(rt) { delete room.named } } @@ -625,7 +626,12 @@ diff --git a/src/host/roster.js b/src/host/roster.js } + function showSettings(room, settings, actor, actorId) { -+ const who = actor?.displayName ?? rt.botOf(actorId)?.name ?? 'Someone' ++ // The web UI has no member session (#1967), so the local user's save arrives with neither an actor nor a ++ // Bot id: the room's implicit owner names it, the way the room line already does. Only a room with no ++ // owner at all falls back to the host's own wording for the local person; a signed-in member and a Bot ++ // acting through its tool still win, as they did. ++ const who = actor?.displayName ?? rt.botOf(actorId)?.name ++ ?? implicitRoomOwner(room, state)?.displayName ?? 'the user' + const said = settings.length === 1 ? settings[0] : `${settings.slice(0, -1).join(', ')} and ${settings.at(-1)}` + try { + void Promise.resolve(rt.postToRoom?.(room, { id: 'face-notice', name: who }, `${who} ${said}`, { quiet: true, notice: true })) diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index b136cd255..f1f415681 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -2567,6 +2567,25 @@ test('writing back the reply mode the room already holds says nothing', async (t assert.deepEqual(events, [], 'a save that writes the same mode back says nothing') }) +// #1967: the local user has no member session (nothing in this repository writes the client's +// `flybot-member-session` key), so a save from the web UI reaches the roster with neither an actor nor a +// Bot id. The line that says who changed what must still name the room's owner, and never read as a +// third party doing it. +test('a settings save with no session names the room owner, not Someone', async (t) => { + const { rt } = host(t, TEAM) + installRoster(rt) + await rt.load() + const events = [] + rt.postToRoom = async (room, sender, text, extra) => { events.push({ room: room.id, sender, text, extra }) } + + await rt.updateRoom('war', { notice: 'Stand up' }) + + assert.equal(events.length, 1, `one save is one line: ${JSON.stringify(events)}`) + assert.equal(events[0].text, 'Alice updated the group notice', 'the local user is named, not someone else') + assert.ok(!events[0].text.includes('Someone'), 'a save by the local user never reads as a third party') + assert.deepEqual(events[0].extra, { quiet: true, notice: true }, 'still quiet and a notice, so no turn opens') +}) + // #904: a roster is stranger-written text, so the envelope that carries one is gated: only the room's // own inviter, only the room this mirror is bound to, and nothing it says widens reach by itself. test('#904: a roster from another home or for another room changes no participant', async (t) => {