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 })