From 07f754613bfd84fe73080d25dbc501ac279c7f92 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 11:33:27 +0800 Subject: [PATCH] fix(rooms): name the pair a room link seated A seat is a pair only when the seat record kept the link's ticket and the Bot of this home that minted it, and the local link accept wrote neither: its group envelopes fell to the plain pair path, named no room, and Bot Social held them with an approval that ends in no active relationship. The link's own record of who it admitted, and the room's lead, name that pair without rewriting anything on load. A seat no link names stays out. Fixes #923 Docs-not-needed: the plugins/social change is one comment and one assertion on the existing pair path; no plugin behaviour, route or setting changed, so docs/subsystems/social.md would only restate the #883 test. The room-share pair itself is documented in docs/subsystems/core-host.md in this branch. Self-check: git diff ...HEAD -- plugins/social shows 3 comment lines and 5 test lines. --- changelog.d/923-room-link-seat-pair.fixed.md | 1 + core/src/host/reach.js | 33 +++++++++++++-- core/src/host/room-membership.js | 11 ++++- core/test/remote-room.test.js | 41 ++++++++++++++++++- docs/subsystems/core-host.md | 2 +- patches/ds-bot/files/src/host/reach.js | 33 +++++++++++++-- .../ds-bot/files/src/host/room-membership.js | 11 ++++- patches/ds-bot/files/test/remote-room.test.js | 41 ++++++++++++++++++- plugins/social/src/social.js | 6 +-- plugins/social/test/social.test.js | 5 +++ 10 files changed, 170 insertions(+), 14 deletions(-) create mode 100644 changelog.d/923-room-link-seat-pair.fixed.md diff --git a/changelog.d/923-room-link-seat-pair.fixed.md b/changelog.d/923-room-link-seat-pair.fixed.md new file mode 100644 index 000000000..c2530c1d0 --- /dev/null +++ b/changelog.d/923-room-link-seat-pair.fixed.md @@ -0,0 +1 @@ +A group that a friend's Bot joined through an invite link now reaches that guest instead of asking Bot Social about a friendship the two homes never had: the seat the link admitted is the one pair the link opened, read from the seat itself or from the link's own record of who it admitted, so a group message or update goes out under the room rather than sitting on an approval that could not be approved. A Bot added to the group by hand is not a link pair and is unchanged ([#923](https://github.com/ThinkFlowLab/FlyBot/issues/923)). diff --git a/core/src/host/reach.js b/core/src/host/reach.js index c63d6df6a..d37d8c77a 100644 --- a/core/src/host/reach.js +++ b/core/src/host/reach.js @@ -204,6 +204,22 @@ function remoteInRoom(state, roomId, peer) { }) } +// The seats each link of this room admitted: the invite's own `homes` (home → the handles it seated) +// is written on the accept and survives even when the seat record itself did not keep the ticket. +function linkSeats(room) { + const out = new Map() + const invites = [room?.localInvite, ...(Array.isArray(room?.localInvites) ? room.localInvites : [])] + for (const invite of invites) { + if (!invite || typeof invite.ticket !== 'string' || !invite.ticket) continue + for (const handles of Object.values(invite.homes ?? {})) { + for (const handle of Array.isArray(handles) ? handles : []) { + if (typeof handle === 'string' && handle && !out.has(handle)) out.set(handle, invite) + } + } + } + return out +} + // #883, #884: the only pairs a room link opens, as { from: local Bot id, to: remote handle, scope: // the room id Bot Social knows the room by, ticket? }. A seat in a room of this home is reached from // the Bot its link named; a room of another home that a ticket seated Bots of mine in is reached from @@ -222,10 +238,21 @@ export function ticketPeers(state) { } continue } + const linkOf = linkSeats(room) for (const p of live) { - if (p.kind !== 'bot' || p.remote !== true || p.admittedBy !== 'ticket' || !p.hostBotId) continue - if (!(room.members ?? []).includes(p.hostBotId)) continue - peers.push({ from: p.hostBotId, to: handleOf(p), scope: String(room.id), ticket: p.ticket }) + if (p.kind !== 'bot' || p.remote !== true) continue + // #923: a seat is a pair when a link of this room admitted it, whether the seat itself kept the + // ticket (`admittedBy: 'ticket'`) or the link's own record of who it admitted is the only trace + // — a room opened before the seat carried it must reach that seat the same way, or its group + // envelopes fall back to a friendship the two homes never had. A friend's Bot added by hand is + // named by no link and stays out. + const ticket = p.admittedBy === 'ticket' ? p.ticket : linkOf.get(handleOf(p))?.ticket + if (!ticket) continue + // The seat names the Bot of this home whose handle it knocked on; a seat written before that + // was recorded is reached from the room's own lead, which is the Bot its link was minted for. + const host = p.hostBotId && (room.members ?? []).includes(p.hostBotId) ? p.hostBotId : room.admin + if (!host || !(room.members ?? []).includes(host)) continue + peers.push({ from: host, to: handleOf(p), scope: String(room.id), ticket }) } } for (const [ticket, claim] of Object.entries(state.roomClaims ?? {})) { diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 0c3f7d450..b655642c2 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -614,6 +614,11 @@ export function install(rt) { } const kind = draft.kind const viaCode = opts?.viaCode === true && draft.kind === 'bot' && !isRemoteDraft(draft) + // #923: a seat one of this room's links admitted names the pair reach.js enumerates. The link's + // ticket, and the Bot of this home whose handle the guest knocked on, are what make that pair + // visible; without them the seat's envelopes fall back to a friendship the two homes never had. + const seatTicket = typeof opts?.ticket === 'string' && opts.ticket ? opts.ticket : undefined + const seatHostBotId = seatTicket && typeof opts?.hostBotId === 'string' && opts.hostBotId ? opts.hostBotId : undefined const published = assertAddable(room, draft, actor, { viaCode }) const role = draft.role === 'admin' ? 'admin' : 'member' const invitedBy = actor?.memberId @@ -646,6 +651,8 @@ export function install(rt) { invitedAt, joinedSeq: line.seq, joinedAt: invitedAt, + ...(seatTicket ? { admittedBy: 'ticket', ticket: seatTicket } : {}), + ...(seatHostBotId ? { hostBotId: seatHostBotId } : {}), } room.participants.push(record) append(room, { @@ -1546,7 +1553,9 @@ export function install(rt) { void rt.notifyRoomShare?.(room, { op: 'join', handle: asker }) } else { const ownerId = actor?.memberId ?? Object.keys(state.members || {})[0] - await rt.addRoomParticipant(room.id, { kind: 'bot', handle: asker }, actor ?? (ownerId ? { memberId: ownerId } : undefined)) + // #923: the seat a link admitted keeps the link's ticket and the Bot whose handle it knocked + // on, so `reach.js` can enumerate the one pair this link opened. + await rt.addRoomParticipant(room.id, { kind: 'bot', handle: asker }, actor ?? (ownerId ? { memberId: ownerId } : undefined), { ticket: invite.ticket, hostBotId: invite.botId ?? room.admin }) } } catch (error) { // Nothing was added, so the record must not say it seated anyone. diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index bd2043cba..7746a403b 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -19,7 +19,7 @@ import { roomVisibleTo, viewerRole, } from '../src/host/room-membership.js' -import { install as installReach, reach } from '../src/host/reach.js' +import { install as installReach, reach, ticketPeers } from '../src/host/reach.js' import { friendBotOwner, friendBotTitle, remoteBotTitle, sharedRoomTitle } from '../src/shared/remote-label.js' import { encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../src/shared/room-share.js' import { fenceUntrusted } from '../src/host/delivery.js' @@ -1477,6 +1477,45 @@ test('#852: the knock asks the room record, and only the accept admits one home' assert.equal(seats().length, 1, 'and seated nobody') }) +// #923: a room link seats a Bot of another home, and that seat is the one pair the link opened: the +// room's own envelopes to it are the room's consent, not a friendship the two homes never had. A seat +// recorded before it kept the ticket (or by a path that did not) is the same pair, read from the link's +// own record of who it admitted; a friend's Bot added by hand is not. +test('#923: a link-admitted seat is the pair the link opened, and a hand-added friend is not', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { alice, bob } = await signAliceAndBob(rt) + await rt.authenticate(alice.sessionToken) + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + rt.state.bots['chief-a'].fields = { social: { member: { home: '@alice.home', handle: '@chief.a.home' } } } + const invite = await rt.mintRoomInvite('war') + const knock = (payload, fromHandle = '@coder.bob.home', fromHome = '@bob.home') => rt.applyInboundRoomShare({ + text: encodeRoomShare(payload), fromHandle, fromHome, fromName: 'Coder', + }) + const accepted = await knock({ op: 'ask', ticket: invite.ticket, accept: true }) + assert.equal(accepted.accepted, true) + const seat = rt.state.rooms.war.participants.find(p => p.remote === true && p.handle === '@coder.bob.home') + assert.equal(seat.admittedBy, 'ticket', 'the accept keeps the link that seated it') + assert.equal(seat.ticket, invite.ticket) + assert.equal(seat.hostBotId, 'chief-a', 'reached from the Bot whose handle it knocked on') + const pairs = () => ticketPeers(rt.state).filter(p => p.to === '@coder.bob.home') + assert.deepEqual(pairs(), [{ from: 'chief-a', to: '@coder.bob.home', scope: 'war', ticket: invite.ticket }], 'one pair, the room Bot Social can check') + + // A room opened before the seat kept the ticket: the link's own `homes` still names it, so the pair + // is the same one, and a group envelope to it is the room's, not a friend's. + delete seat.admittedBy + delete seat.ticket + delete seat.hostBotId + assert.deepEqual(pairs().map(p => [p.from, p.ticket]), [['chief-a', invite.ticket]], 'the link record alone still opens the pair') + + // A friend's Bot added by hand is nobody's link seat, whatever else is true about it. + rt.state.rooms.war.participants.push({ kind: 'bot', remote: true, id: '@friend.eve.home', handle: '@friend.eve.home', joinedSeq: 999, joinedAt: 2 }) + assert.equal(ticketPeers(rt.state).some(p => p.to === '@friend.eve.home'), false, 'a hand-added friend is not a room pair') + void bob +}) + // #909: an inbound envelope may say who invited a room for display, but it never decides which room it // may touch, which room gets created, or who wrote a line. The transport names the Bot that delivered it, diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index b67fa44ae..7dbe5b4af 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -164,7 +164,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. 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. `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, an unverified entry displayed but never addressed, an identity-less one shown as not connected. 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. +- 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. 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. `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, an unverified entry displayed but never addressed, an identity-less one shown as not connected. 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 handles a line calls in the envelope's `calls` (resolved where the room's mentions are resolved, so a fenced or quoted `@name` is literal text), plus its `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/reach.js b/patches/ds-bot/files/src/host/reach.js index c63d6df6a..d37d8c77a 100644 --- a/patches/ds-bot/files/src/host/reach.js +++ b/patches/ds-bot/files/src/host/reach.js @@ -204,6 +204,22 @@ function remoteInRoom(state, roomId, peer) { }) } +// The seats each link of this room admitted: the invite's own `homes` (home → the handles it seated) +// is written on the accept and survives even when the seat record itself did not keep the ticket. +function linkSeats(room) { + const out = new Map() + const invites = [room?.localInvite, ...(Array.isArray(room?.localInvites) ? room.localInvites : [])] + for (const invite of invites) { + if (!invite || typeof invite.ticket !== 'string' || !invite.ticket) continue + for (const handles of Object.values(invite.homes ?? {})) { + for (const handle of Array.isArray(handles) ? handles : []) { + if (typeof handle === 'string' && handle && !out.has(handle)) out.set(handle, invite) + } + } + } + return out +} + // #883, #884: the only pairs a room link opens, as { from: local Bot id, to: remote handle, scope: // the room id Bot Social knows the room by, ticket? }. A seat in a room of this home is reached from // the Bot its link named; a room of another home that a ticket seated Bots of mine in is reached from @@ -222,10 +238,21 @@ export function ticketPeers(state) { } continue } + const linkOf = linkSeats(room) for (const p of live) { - if (p.kind !== 'bot' || p.remote !== true || p.admittedBy !== 'ticket' || !p.hostBotId) continue - if (!(room.members ?? []).includes(p.hostBotId)) continue - peers.push({ from: p.hostBotId, to: handleOf(p), scope: String(room.id), ticket: p.ticket }) + if (p.kind !== 'bot' || p.remote !== true) continue + // #923: a seat is a pair when a link of this room admitted it, whether the seat itself kept the + // ticket (`admittedBy: 'ticket'`) or the link's own record of who it admitted is the only trace + // — a room opened before the seat carried it must reach that seat the same way, or its group + // envelopes fall back to a friendship the two homes never had. A friend's Bot added by hand is + // named by no link and stays out. + const ticket = p.admittedBy === 'ticket' ? p.ticket : linkOf.get(handleOf(p))?.ticket + if (!ticket) continue + // The seat names the Bot of this home whose handle it knocked on; a seat written before that + // was recorded is reached from the room's own lead, which is the Bot its link was minted for. + const host = p.hostBotId && (room.members ?? []).includes(p.hostBotId) ? p.hostBotId : room.admin + if (!host || !(room.members ?? []).includes(host)) continue + peers.push({ from: host, to: handleOf(p), scope: String(room.id), ticket }) } } for (const [ticket, claim] of Object.entries(state.roomClaims ?? {})) { diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 0c3f7d450..b655642c2 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -614,6 +614,11 @@ export function install(rt) { } const kind = draft.kind const viaCode = opts?.viaCode === true && draft.kind === 'bot' && !isRemoteDraft(draft) + // #923: a seat one of this room's links admitted names the pair reach.js enumerates. The link's + // ticket, and the Bot of this home whose handle the guest knocked on, are what make that pair + // visible; without them the seat's envelopes fall back to a friendship the two homes never had. + const seatTicket = typeof opts?.ticket === 'string' && opts.ticket ? opts.ticket : undefined + const seatHostBotId = seatTicket && typeof opts?.hostBotId === 'string' && opts.hostBotId ? opts.hostBotId : undefined const published = assertAddable(room, draft, actor, { viaCode }) const role = draft.role === 'admin' ? 'admin' : 'member' const invitedBy = actor?.memberId @@ -646,6 +651,8 @@ export function install(rt) { invitedAt, joinedSeq: line.seq, joinedAt: invitedAt, + ...(seatTicket ? { admittedBy: 'ticket', ticket: seatTicket } : {}), + ...(seatHostBotId ? { hostBotId: seatHostBotId } : {}), } room.participants.push(record) append(room, { @@ -1546,7 +1553,9 @@ export function install(rt) { void rt.notifyRoomShare?.(room, { op: 'join', handle: asker }) } else { const ownerId = actor?.memberId ?? Object.keys(state.members || {})[0] - await rt.addRoomParticipant(room.id, { kind: 'bot', handle: asker }, actor ?? (ownerId ? { memberId: ownerId } : undefined)) + // #923: the seat a link admitted keeps the link's ticket and the Bot whose handle it knocked + // on, so `reach.js` can enumerate the one pair this link opened. + await rt.addRoomParticipant(room.id, { kind: 'bot', handle: asker }, actor ?? (ownerId ? { memberId: ownerId } : undefined), { ticket: invite.ticket, hostBotId: invite.botId ?? room.admin }) } } catch (error) { // Nothing was added, so the record must not say it seated anyone. diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index bd2043cba..7746a403b 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -19,7 +19,7 @@ import { roomVisibleTo, viewerRole, } from '../src/host/room-membership.js' -import { install as installReach, reach } from '../src/host/reach.js' +import { install as installReach, reach, ticketPeers } from '../src/host/reach.js' import { friendBotOwner, friendBotTitle, remoteBotTitle, sharedRoomTitle } from '../src/shared/remote-label.js' import { encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../src/shared/room-share.js' import { fenceUntrusted } from '../src/host/delivery.js' @@ -1477,6 +1477,45 @@ test('#852: the knock asks the room record, and only the accept admits one home' assert.equal(seats().length, 1, 'and seated nobody') }) +// #923: a room link seats a Bot of another home, and that seat is the one pair the link opened: the +// room's own envelopes to it are the room's consent, not a friendship the two homes never had. A seat +// recorded before it kept the ticket (or by a path that did not) is the same pair, read from the link's +// own record of who it admitted; a friend's Bot added by hand is not. +test('#923: a link-admitted seat is the pair the link opened, and a hand-added friend is not', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { alice, bob } = await signAliceAndBob(rt) + await rt.authenticate(alice.sessionToken) + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + rt.state.bots['chief-a'].fields = { social: { member: { home: '@alice.home', handle: '@chief.a.home' } } } + const invite = await rt.mintRoomInvite('war') + const knock = (payload, fromHandle = '@coder.bob.home', fromHome = '@bob.home') => rt.applyInboundRoomShare({ + text: encodeRoomShare(payload), fromHandle, fromHome, fromName: 'Coder', + }) + const accepted = await knock({ op: 'ask', ticket: invite.ticket, accept: true }) + assert.equal(accepted.accepted, true) + const seat = rt.state.rooms.war.participants.find(p => p.remote === true && p.handle === '@coder.bob.home') + assert.equal(seat.admittedBy, 'ticket', 'the accept keeps the link that seated it') + assert.equal(seat.ticket, invite.ticket) + assert.equal(seat.hostBotId, 'chief-a', 'reached from the Bot whose handle it knocked on') + const pairs = () => ticketPeers(rt.state).filter(p => p.to === '@coder.bob.home') + assert.deepEqual(pairs(), [{ from: 'chief-a', to: '@coder.bob.home', scope: 'war', ticket: invite.ticket }], 'one pair, the room Bot Social can check') + + // A room opened before the seat kept the ticket: the link's own `homes` still names it, so the pair + // is the same one, and a group envelope to it is the room's, not a friend's. + delete seat.admittedBy + delete seat.ticket + delete seat.hostBotId + assert.deepEqual(pairs().map(p => [p.from, p.ticket]), [['chief-a', invite.ticket]], 'the link record alone still opens the pair') + + // A friend's Bot added by hand is nobody's link seat, whatever else is true about it. + rt.state.rooms.war.participants.push({ kind: 'bot', remote: true, id: '@friend.eve.home', handle: '@friend.eve.home', joinedSeq: 999, joinedAt: 2 }) + assert.equal(ticketPeers(rt.state).some(p => p.to === '@friend.eve.home'), false, 'a hand-added friend is not a room pair') + void bob +}) + // #909: an inbound envelope may say who invited a room for display, but it never decides which room it // may touch, which room gets created, or who wrote a line. The transport names the Bot that delivered it, diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index f39ec794d..940bac88d 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -1616,9 +1616,9 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } const attachments = await uploadAll(msg.attachments, member.bot_id) // A 1:1 line between two Bots that are in one open room here names that room (#881): the - // room is the reach the server can check, and it is the consent that lifts its own hold. Room - // lines themselves are the group's record and name no room: a local room has no id on the - // server, so naming one would be a lie it refuses. + // room is the reach the server can check, and it is the consent that lifts its own hold. A + // room line names no room here, and the room's id is not what is missing — the link's own + // pair names it (sendTicketShare). This path has no pair, so it has no grant to name. const roomId = msg.via === 'room' || msg.via === 'room-share' ? undefined : roomContextFor(msg.from, bot.handle) const sent = await client.callTool('send_as_member', { from_bot_id: member.bot_id, to: bot.handle, text: msg.text, ...(attachments.length ? { attachments } : {}), ...(roomId ? { room_id: roomId } : {}) }) .catch((error) => { diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 2da644c16..7db8cfe3b 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -10291,6 +10291,11 @@ test('#883: a room share reaches a seat a link opened, under that room only, and assert.equal(out.action, 'deny', `${why}: ${JSON.stringify(out)}`) } assert.equal(sends().length, 1, 'none of those reached Bot Social') + // #923: the same envelope the owner approves on a card goes out by the same pair, so approving a + // held room share names the room instead of asking Bot Social about a friendship there is not. + const approved = await ctx.flybot.bot.send({ from: 'a', to: '@guest.eve.home', text: share, via: 'room-share', approved: true, meta: { roomId: 'war', scope: 'war' } }) + assert.equal(approved.action, 'allow', JSON.stringify(approved)) + assert.equal(sends().at(-1).args.room_id, 'war', 'approved, it names the room the link was minted for') // The roster sync drops friends' Bots that are no longer listed; a seat is not one (live two-home run: // the guest's Bot "left" the room right after the first line). await social.remoteBots({ fresh: true })