From 1debb12dc0a1d2e40d527f0ae62f467a1ff55ae2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 12:37:19 +0000 Subject: [PATCH] Send a shared-room leave while the seat is still reachable Removing a friend's Bot marked the seat left before the leave went out. Reach only allows current members, so the other home never hid the room, and a later line or an in-flight join opened it again. Fixes #1167 Co-authored-by: Danyang Chen --- changelog.d/1167.fixed.md | 1 + core/src/host/room-membership.js | 78 ++++++++---- core/test/remote-room.test.js | 119 ++++++++++++++++++ core/test/room-ticket-seat.test.js | 3 +- docs/subsystems/core-host.md | 2 +- patches/ds-bot/0012-room-membership.patch | 60 ++++----- .../ds-bot/files/src/host/room-membership.js | 78 ++++++++---- patches/ds-bot/files/test/remote-room.test.js | 119 ++++++++++++++++++ .../files/test/room-ticket-seat.test.js | 3 +- 9 files changed, 378 insertions(+), 85 deletions(-) create mode 100644 changelog.d/1167.fixed.md diff --git a/changelog.d/1167.fixed.md b/changelog.d/1167.fixed.md new file mode 100644 index 00000000..ef820418 --- /dev/null +++ b/changelog.d/1167.fixed.md @@ -0,0 +1 @@ +Removing someone's Bot from a shared group now drops that room on their home. The other home was still listing the room and could keep receiving and sending in it, because the leave was sent only after the seat had already gone and a later line could open the room again. ([#1167](https://github.com/ThinkFlowLab/FlyBot/issues/1167)) diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 5177e601..6e0fe5d0 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -752,6 +752,14 @@ export function install(rt) { || (kind === 'bot' && (state.bots?.[me.id]?.ownerId === actor.memberId || me.ownerId === actor.memberId || room.sharedFrom?.ownerId === actor.memberId)) ) if (kind === 'bot' && room.origin !== 'inbound-share' && room.origin !== 'face-to-face' && !me.remote && botsLeft.length < 2 && !ownerLeave) throw invalid('A group chat needs at least two Bots') + // #1167: reach and a room link's pairs only see who is still in the room. Tell the other home + // before leftSeq, or that leave is refused and their copy of the room stays open. + if (me.remote) { + await Promise.resolve(rt.notifyRoomShare?.(room, { op: 'leave', handle: me.handle || me.id })) + .catch(error => rt.warn?.('ds-bot: a seat\'s leave was not sent: %s', error)) + } else if (kind === 'bot' && room.origin === 'inbound-share' && room.sharedFrom?.ticket) { + await sendTicketLeave(room, me).catch(error => rt.warn?.('ds-bot: leaving %s was not sent: %s', room.name, error)) + } const line = append(room, { kind: 'left', who: me.displayName, @@ -767,12 +775,10 @@ export function install(rt) { } syncMembers(room) await rt.save() - if (me.remote) { - void Promise.resolve(rt.notifyRoomShare?.(room, { op: 'leave', handle: me.handle || me.id })) - .then(() => (isTicketSeat(me) ? settleTicket(room, me.ticket, me) : undefined)) - .catch(error => rt.warn?.('ds-bot: a seat\'s leave was not settled: %s', error)) + if (me.remote && isTicketSeat(me)) { + await settleTicket(room, me.ticket, me).catch(error => rt.warn?.('ds-bot: a seat\'s leave was not settled: %s', error)) } else if (kind === 'bot' && room.origin === 'inbound-share' && room.sharedFrom?.ticket) { - void leaveTicketRoom(room, me).catch(error => rt.warn?.('ds-bot: leaving %s was not sent: %s', room.name, error)) + await finishTicketLeave(room).catch(error => rt.warn?.('ds-bot: leaving %s was not settled: %s', room.name, error)) } else if (kind === 'bot' && !me.remote) { reshareRoster(room) } @@ -967,7 +973,9 @@ export function install(rt) { // and is told about the room by that Bot. const isTicketSeat = p => p?.remote === true && p.admittedBy === 'ticket' function seatSender(room, seat) { - const botId = seat.hostBotId && rt.botOf?.(seat.hostBotId) && room.members?.includes(seat.hostBotId) ? seat.hostBotId : undefined + const named = seat.hostBotId && rt.botOf?.(seat.hostBotId) && room.members?.includes(seat.hostBotId) ? seat.hostBotId : undefined + // The same fallback ticketPeers uses: a seat written before hostBotId still leaves from the room lead. + const botId = named ?? (room.admin && rt.botOf?.(room.admin) && room.members?.includes(room.admin) ? room.admin : undefined) if (!botId) return undefined const member = state.bots?.[botId]?.fields?.social?.member return { botId, from: { ...shareInviter(room), inviterHandle: member?.handle, ...(member?.home ? { inviterHome: member.home } : {}) } } @@ -2024,28 +2032,30 @@ export function install(rt) { } // The guest's side of a seat (#884): a Bot of mine that a ticket seated leaves the room it mirrors. - // The inviter's home hears it from that Bot, and when none of mine is left the claim is given back. - async function leaveTicketRoom(room, me) { + // The inviter's home hears it from that Bot. Sent while the Bot is still seated (#1167): after + // leftSeq the pair is gone and the host never hears the leave. The claim is given back only once + // that Bot is no longer current, so the "am I the last?" check stays after leftSeq. + async function sendTicketLeave(room, me) { const inviter = room.sharedFrom?.inviterHandle const scope = room.sharedFrom?.remoteRoomId - if (inviter && scope) { - await sendShareTo(room, { handle: inviter, id: inviter }, { - op: 'leave', - roomId: scope, - name: room.name, - invitedHandle: inviter, - seq: room.seq ?? 0, - ticket: room.sharedFrom.ticket, - }, 'room-share', me.id, scope) - } + if (!inviter || !scope) return + await sendShareTo(room, { handle: inviter, id: inviter }, { + op: 'leave', + roomId: scope, + name: room.name, + invitedHandle: inviter, + seq: room.seq ?? 0, + ticket: room.sharedFrom.ticket, + }, 'room-share', me.id, scope) + } + async function finishTicketLeave(room) { const stays = currentParticipants(room).some(p => p.kind === 'bot' && !p.remote) - if (!stays) { - if (state.roomClaims?.[room.sharedFrom.ticket]) { - delete state.roomClaims[room.sharedFrom.ticket] - await rt.save?.() - } - await endServerTicket(room.sharedFrom.ticket) + if (stays || !room.sharedFrom?.ticket) return + if (state.roomClaims?.[room.sharedFrom.ticket]) { + delete state.roomClaims[room.sharedFrom.ticket] + await rt.save?.() } + await endServerTicket(room.sharedFrom.ticket) } // A seat of this home's own room that the sender is, for a share addressed to that room. @@ -2093,6 +2103,9 @@ export function install(rt) { // `op: 'line'` envelope to the friend who invited this Bot, under *their* room id, one hop on, // carrying the handles it calls there. if (room.origin === 'inbound-share' && !isFaceRoom(room)) { + // #1167: a room the host already took back does not keep sending, even from a Session that is + // still open. sharedFrom.localBotId would otherwise still name a sender after everyone left. + if (room.hidden || !currentParticipants(room).some(p => p.kind === 'bot' && !p.remote)) return const inviter = room.sharedFrom?.inviterHandle if (!inviter || !rt.flybotSend) return // A Bot of mine a ticket seated speaks for itself, under that room's scope (#884). @@ -2305,7 +2318,7 @@ export function install(rt) { }) } - function markInboundLeft(room) { + function markInboundLeft(room, removedSeq) { const at = Date.now() const line = append(room, { kind: 'left', who: room.sharedFrom?.inviterName || 'the room', authorKind: 'bot', text: '' }) for (const p of currentParticipants(room)) { @@ -2313,6 +2326,10 @@ export function install(rt) { p.leftAt = at } room.hidden = true + // #1167: the host's seq at the leave. A join at or before it is a roster refresh already in + // flight, not a re-invite; a later join may open the room again. + const seq = Number(removedSeq) + room.sharedFrom = { ...(room.sharedFrom ?? {}), removedSeq: Number.isFinite(seq) ? seq : (room.seq ?? line.seq) } syncMembers(room) return room } @@ -2397,11 +2414,19 @@ export function install(rt) { } syncMembers(room) } else { - markInboundLeft(room) + markInboundLeft(room, data.seq) } await rt.save?.() return { handled: true, room } } + // #1167: a line, or a join that was already on its way when the seat was removed, must not open + // a room a leave just hid. A newer join is the host adding this Bot again. + if (room?.hidden) { + const removed = Number(room.sharedFrom?.removedSeq) + const seq = Number(data.seq) + const newerJoin = data.op === 'join' && Number.isFinite(seq) && (!Number.isFinite(removed) || seq > removed) + if (!newerJoin) return { handled: true, refused: 'left', room } + } if (!room) { room = await rt.recordInboundSharedRoom({ name: data.name, @@ -2419,6 +2444,7 @@ export function install(rt) { }, actor || (ownerId ? { memberId: ownerId } : undefined)) } else { delete room.hidden + if (room.sharedFrom) delete room.sharedFrom.removedSeq // A ticket seats each Bot of mine on its own (#878): the join names one, and only that one comes // back or is added. A friend's share keeps the room's one Bot. const seatedHere = ticket && data.op === 'join' && localBotId diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 01b0ce90..7fb25ac6 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -2205,3 +2205,122 @@ test('#904: a seat\'s line is attributed to the seat, not to whatever the envelo const invented = room.log.filter(entry => String(entry.text).includes('line 3')).at(-1) assert.equal(invented.authorId, '@chief.bob.home', 'an invented handle never becomes an author') }) + +const BOB_HOME = { + version: 2, + revision: 1, + mainBotIds: ['chief-b'], + members: { 'owner-b': { id: 'owner-b', createdAt: 1, designatedChiefId: 'chief-b' } }, + bots: { + 'chief-b': { + id: 'chief-b', sessionId: 'chief-b', name: 'Chief', instructions: '', createdAt: 1, ownerId: 'owner-b', + fields: { social: { member: { handle: '@chief.bob.home', home: '@bob.home', ownerName: 'Bob' } } }, + }, + }, + rooms: {}, + exchanges: [], + exchangeTurns: {}, + questions: {}, +} + +const settleShares = async () => { + for (let i = 0; i < 12; i += 1) await new Promise(resolve => setImmediate(resolve)) +} + +// #1167: adding a friend's Bot is allowed by the seat (an open room link, no person-grant). +// Reach only treats current participants as reachable, so a leave sent after leftSeq never +// arrives and the other home keeps the room. +test('#1167: removing a friend\'s Bot drops that home\'s room, and later lines do not return', async (t) => { + const a = host(t, TEAM) + const b = host(t, BOB_HOME) + await a.rt.load() + await b.rt.load() + const invite = await a.rt.createInvitation({ createdByMemberId: 'owner-1' }) + const alice = await a.rt.redeemInvitation(invite.token, { displayName: 'Alice' }) + const actorA = await a.rt.authenticate(alice.sessionToken) + const inviteB = await b.rt.createInvitation({ createdByMemberId: 'owner-b' }) + const bob = await b.rt.redeemInvitation(inviteB.token, { displayName: 'Bob' }) + const actorB = await b.rt.authenticate(bob.sessionToken) + a.rt.state.bots['chief-a'].fields = { social: { member: { handle: '@chief.alice.home', home: '@alice.home', ownerName: 'Alice' } } } + a.rt.state.bots['chief-b'].fields = { social: { member: { handle: '@helper.alice.home', home: '@alice.home', ownerName: 'Alice' } } } + publish(a.rt, [{ handle: '@chief.bob.home', name: 'Chief', home: '@bob.home', homeName: 'Bob', owner: 'Bob', plugin: 'social', remote: true }]) + // No person-grant. An open link is the consent that lets this home's Bots speak in the room. + a.rt.roomInviteServer = { mint: async () => ({ ticket: 'Tkt0001AbCdEfGhIjKlMnO', expiresAt: null }) } + await a.rt.mintRoomInvite('war', actorA) + const traffic = [] + const wire = (from, to, home) => { + from.rt.flybotSend = async (msg) => { + const verdict = from.rt.reach(msg) + traffic.push({ op: parseRoomShare(msg.text)?.op, action: verdict.action, to: msg.to }) + if (verdict.action !== 'allow') return { action: 'deny', reason: verdict.reason } + const localBotId = Object.keys(to.rt.state.bots).find(id => to.rt.state.bots[id]?.fields?.social?.member?.handle === msg.to) + const sender = from.rt.state.bots[msg.from] + await to.rt.applyInboundRoomShare({ + text: msg.text, + fromHandle: sender?.fields?.social?.member?.handle, + fromHome: home, + fromName: sender?.fields?.social?.member?.ownerName || 'Alice', + localBotId, + }) + return { action: 'sent' } + } + } + wire(a, b, '@alice.home') + wire(b, a, '@bob.home') + await a.rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@chief.bob.home' }, actorA) + await settleShares() + const guest = () => Object.values(b.rt.state.rooms).find(room => room.origin === 'inbound-share') + assert.ok(guest(), 'the add projected the room on the friend\'s home') + assert.equal(traffic.some(item => item.op === 'join' && item.action === 'allow'), true, 'the join left while the seat was live') + const hello = await a.rt.postRoomMessage('war', { text: '测1' }, actorA) + await a.rt.fanoutRoomLine(a.rt.state.rooms.war, hello) + await settleShares() + assert.ok(guest().log.some(line => String(line.text).includes('测1')), 'a line while seated arrives') + + await a.rt.removeRoomParticipant('war', { kind: 'bot', id: '@chief.bob.home' }, actorA) + await settleShares() + const leave = traffic.findLast(item => item.op === 'leave') + assert.equal(leave?.action, 'allow', 'the leave is sent while the seat can still be reached') + assert.equal(guest().hidden, true, 'the friend\'s home hid the room') + assert.equal(roomVisibleTo(b.rt.state, actorB, guest().id), false, 'the room drops out of that home\'s list') + + const after = await a.rt.postRoomMessage('war', { text: '踢后测4' }, actorA) + await a.rt.fanoutRoomLine(a.rt.state.rooms.war, after) + await settleShares() + assert.equal(guest().log.some(line => String(line.text).includes('踢后测4')), false, 'a line after the remove does not arrive') + const before = a.rt.state.rooms.war.log.length + await b.rt.fanoutRoomLine(guest(), { who: 'Bob', authorId: 'owner-b', authorKind: 'human', text: 'B踢后测5', seq: (guest().seq ?? 0) + 1 }) + await settleShares() + assert.equal(a.rt.state.rooms.war.log.length, before, 'the removed home cannot send into the room') + assert.equal(traffic.findLast(item => item.op === 'line' && String(item.to).includes('alice'))?.action, undefined) +}) + +test('#1167: a hidden shared room stays hidden for a later line or a stale join, and a newer join reopens it', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const share = (op, seq, extra = {}) => encodeRoomShare({ + op, roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@chief.bob.home', seq, ...extra, + }) + const deliver = text => rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', text, localBotId: 'chief-b', + }, actorB) + const joined = await deliver(share('join', 4)) + const room = joined.room + assert.equal(room.hidden, undefined) + await deliver(share('leave', 8)) + assert.equal(room.hidden, true, 'the leave hid the mirror') + const line = await deliver(share('line', 9, { who: 'Alice', authorKind: 'human', text: '踢后测4' })) + assert.equal(line.refused, 'left', 'a line after the leave is refused') + assert.equal(room.hidden, true, 'the line does not bring the room back') + assert.equal(room.log.some(entry => String(entry.text).includes('踢后测4')), false) + const stale = await deliver(share('join', 8, { roster: [{ handle: '@chief.alice.home', name: 'Chief', ownerName: 'Alice' }] })) + assert.equal(stale.refused, 'left', 'a join from before the remove is not a re-invite') + assert.equal(room.hidden, true) + const again = await deliver(share('join', 12)) + assert.equal(again.refused, undefined, 'a later join is a new invite') + assert.equal(room.hidden, undefined, 'the room opens again') + assert.equal(roomVisibleTo(rt.state, actorB, room.id), true) +}) diff --git a/core/test/room-ticket-seat.test.js b/core/test/room-ticket-seat.test.js index 8e1928e7..366a6ab5 100644 --- a/core/test/room-ticket-seat.test.js +++ b/core/test/room-ticket-seat.test.js @@ -351,8 +351,9 @@ test('#892: withdrawing the link stops new joins and keeps the seat; removing th assert.equal(mirror.hidden, true, 'the guest heard the leave') assert.equal(a.rt.state.rooms.war.localInvites.length, 0, 'a withdrawn link that seats nobody is dropped') assert.equal(mayShareRoom(a.rt.state, a.rt.state.rooms.war, 'writer-a'), false, 'with no link left, Writer is private again') + const beforeSend = log.length await b.rt.fanoutRoomLine(mirror, { who: 'Chief', botId: 'chief-b', authorId: 'chief-b', authorKind: 'bot', text: 'still there?', seq: 100 }) - assert.equal(log.at(-1).delivered, false) + assert.equal(log.length, beforeSend, 'the guest sends nothing once the host has removed them') const before = Object.keys(a.rt.state.rooms).length const stray = await a.rt.applyInboundRoomShare({ text: `FLYBOT-ROOM-SHARE\n${JSON.stringify({ v: 1, op: 'line', roomId: 'war', text: 'hi', seq: 1 })}`, fromHandle: '@chief.bob.home', fromHome: '@bob.home' }) assert.equal(stray.refused, 'not-seated') diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 6f1251bd..8cbba86f 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -213,7 +213,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. 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. `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/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 80f34e03..9b2c0424 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -790,7 +790,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js const ROOM_LOG_MAX = 60 const ROOM_FIRST_LOOK = 12 -@@ -31,6 +39,10 @@ +@@ -31,6 +31,10 @@ const ROOM_EXTRA_TURNS = 4 const ROOM_ADMIN_EXTRA_TURNS = 4 const ROOM_TURNS_EACH = 2 const ROOM_ADMIN_TURNS_EACH = 4 @@ -801,7 +801,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js // Logs written before rooms were versioned get sequence numbers in place. const ensureRoomLog = (room) => { -@@ -41,9 +53,64 @@ +@@ -41,9 +41,64 @@ const ensureRoomLog = (room) => { room.seen ??= {} } @@ -867,7 +867,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } // A member's own lines are already in its Session, so only the others' are news. -@@ -58,6 +125,9 @@ +@@ -58,6 +58,9 @@ const lineText = (line) => { if (line.kind === 'resumed') return `[${line.who}'s work here was cut off when DeepSeek Harness restarted; it is picking it back up and posts when done]` if (line.kind === 'cut') return `[${line.who}'s work here was cut off by a restart again, so it stopped; ask it to go on if it still matters]` if (line.kind === 'stopped') return `[The user stopped ${line.who}'s work here]` @@ -877,7 +877,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js return `${line.who}: ${line.text}` } const transcript = lines => lines.map(lineText).join('\n') -@@ -86,6 +156,11 @@ +@@ -86,6 +86,11 @@ export const referenceNote = references => (references?.length : '') const ROOM_REPLY = 'Reply with your message for the group, no name prefix: in a discussion, one or two short messages. When you are asked to do something, do it now with your tools. The group does not wait for you, and your final reply is posted here when you finish, so give the result, never a promise to do it later. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.' @@ -889,7 +889,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js const ROOM_CALLS = 'Writing "@Name" calls that member next, to answer or to do what you ask; when it finishes work you gave it, you answer its result. To only mention a member, write its name without "@".' const slotLabel = (slot) => { -@@ -100,52 +175,160 @@ +@@ -100,52 +100,160 @@ const slotLabel = (slot) => { return slot.name } @@ -938,15 +938,6 @@ diff --git a/src/host/groups.js b/src/host/groups.js - if (known !== undefined) { - await rt.ensureCapabilities(known) - return { id: known, brief: undefined } -- } -- const id = await rt.createSession(`${member.name} · ${room.name}`, { dir: rt.workDirOf({ botId: member.id, roomId: room.id }) }) -- room.sessions[member.id] = id -- rt.groupOwners.set(id, { botId: member.id, roomId: room.id }) -- delete room.seen[member.id] -- const ref = await rt.desiredModel(member) -- if (ref !== undefined) { -- await rt.selectSessionModel(id, splitRef(ref)) -- .catch(error => rt.warn('ds-bot: model for %s in %s not applied: %s', member.name, room.name, error)) + // The brief is the member's owner's private chat with the Bot. In a room where + // another owner sits it stays out unless that owner shared it with this room (#53). + const briefAllowed = (room, member) => { @@ -959,6 +950,15 @@ diff --git a/src/host/groups.js b/src/host/groups.js + else if (p.remote) owners.add(p.handle) + else owners.add(rt.botOf(p.id)?.ownerId) } +- const id = await rt.createSession(`${member.name} · ${room.name}`, { dir: rt.workDirOf({ botId: member.id, roomId: room.id }) }) +- room.sessions[member.id] = id +- rt.groupOwners.set(id, { botId: member.id, roomId: room.id }) +- delete room.seen[member.id] +- const ref = await rt.desiredModel(member) +- if (ref !== undefined) { +- await rt.selectSessionModel(id, splitRef(ref)) +- .catch(error => rt.warn('ds-bot: model for %s in %s not applied: %s', member.name, room.name, error)) +- } - await rt.applyPermission(member, id) - .catch(error => rt.warn('ds-bot: permission for %s in %s not applied: %s', member.name, room.name, error)) - await rt.save() @@ -1077,7 +1077,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js const lines = unseenLines(room, member.id) const earlier = lines.filter(line => line.seq < round.startSeq).slice(-ROOM_EARLIER_MAX) const current = lines.filter(line => line.seq >= round.startSeq) -@@ -153,14 +336,9 @@ +@@ -153,14 +153,9 @@ export function install(rt) { const index = round.slots.findIndex(slot => slot.status === 'speaking') const before = index const after = round.slots.length - index - 1 @@ -1093,7 +1093,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js earlier.length ? `Earlier in the group, before this round (context only):\n${transcript(earlier)}` : '', current.length ? `This round so far${spokeThisRound ? ', since your last message' : ', starting with the new message'} (oldest first):\n${transcript(current)}` -@@ -169,90 +347,27 @@ +@@ -169,90 +169,27 @@ export function install(rt) { working.length > 0 ? `Still at work on what they were asked; each posts here when done: ${working.map(item => `${item.bot.name} (${minutesSince(item.since)} min)`).join(', ')}.` : '', `Speaking order for this round: ${round.slots.map(slotLabel).join(' → ')}.`, `You are number ${index + 1} of ${round.slots.length}. ${before === 0 ? 'Nobody spoke before you' : `${before} went before you`}${after === 0 ? ', and you are the last.' : `; ${after} after you will see your message.`}`, @@ -1191,7 +1191,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js const waiting = round.slots.findIndex((other, at) => at > index && other.id === peer.id && other.status === 'waiting') const called = waiting === -1 ? { id: peer.id, name: peer.name, status: 'waiting' } : round.slots.splice(waiting, 1)[0] called.note = note -@@ -285,34 +400,138 @@ +@@ -285,34 +285,138 @@ export function install(rt) { // Session's inbox, so it does not open another round when this one ends. Returns // the number of turns added, which the round's limit allows for. function drainRoomInbox(room, round, turns, index, agent) { @@ -1338,7 +1338,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js if (index >= round.slots.length) break const slot = round.slots[index] const member = rt.botOf(slot.id) -@@ -321,6 +540,13 @@ +@@ -321,6 +321,13 @@ export function install(rt) { slot.status = 'left' continue } @@ -1352,7 +1352,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js slot.status = 'speaking' round.speaking = member.id round.turns += 1 -@@ -339,7 +565,13 @@ +@@ -339,7 +339,13 @@ export function install(rt) { const prompt = roomPrompt(room, member, round) round.shown?.push(member.id) const text = session.brief === undefined ? prompt : `${session.brief}\n\n${prompt}` @@ -1367,7 +1367,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js round.speaking = null if (reply?.failure !== undefined) { // Not a pass: the user sees why the member is silent, and the round goes on. -@@ -366,9 +598,18 @@ +@@ -366,9 +366,18 @@ export function install(rt) { if (reply?.missed) round.replies.push({ kind: 'missed', botId: member.id, name: member.name, color: member.color, time: Date.now() }) continue } @@ -1388,7 +1388,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js void rt.save().catch(() => {}) // Only the admin's "@all" calls everyone, so a member cannot set off a storm. const leads = rt.isAdminOf(room, member.id) -@@ -377,7 +618,11 @@ +@@ -377,7 +377,11 @@ export function install(rt) { const { all, found: named } = mentionsIn(reply, everyone) const found = named.filter(other => other.id !== member.id) const callees = leads && all ? [...found, ...others.filter(other => !found.includes(other))] : found @@ -1401,7 +1401,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } } -@@ -397,38 +642,45 @@ +@@ -397,38 +397,45 @@ export function install(rt) { rt.roomInbox.delete(room.id) const posts = queued.length > 0 ? queued : [{ text: '' }] const startSeq = room.seq + 1 @@ -1467,7 +1467,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js speaking: null, turns: 0, replies: [], -@@ -436,6 +688,7 @@ +@@ -436,6 +436,7 @@ export function install(rt) { failed: [], started: [], } @@ -1475,7 +1475,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js for (const post of posts.filter(item => item.failed)) { const bot = rt.botOf(post.botId) const { code, status, message } = post.failed -@@ -450,17 +703,25 @@ +@@ -450,17 +450,25 @@ export function install(rt) { appendRoom(room, { who: name, botId: post.botId, kind, text: '' }) round.replies.push({ kind, botId: post.botId, name, color: bot?.color, time: Date.now() }) } @@ -1502,7 +1502,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } await rt.save() for (const post of posts) { -@@ -471,6 +732,41 @@ +@@ -471,6 +471,41 @@ export function install(rt) { }, })) @@ -1544,7 +1544,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js // Private context enters a group only through this tool, and the group sees each use. ctx.tools.register(defineTool({ name: 'read_own_chat', -@@ -539,7 +835,9 @@ +@@ -539,7 +539,9 @@ export function install(rt) { const { room, error } = rt.pickRoom(args.group, self) if (error) return error ensureRoomLog(room) @@ -1555,7 +1555,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js return lines.length === 0 ? `"${room.name}" has no messages yet.` : `Latest in "${room.name}" (oldest first):\n${transcript(lines)}` }, })) -@@ -548,46 +846,207 @@ +@@ -548,46 +548,207 @@ export function install(rt) { // the admin answers alone in admin mode, or first of everyone. What a member posts // after its round moved on is answered by the member that asked for it; work the // user asked for is left for the user to read. @@ -1768,7 +1768,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js .catch(error => rt.warn('ds-bot: late group reply from %s failed: %s', member.name, error)) } rt.lateRoomReply = lateRoomReply -@@ -599,9 +1058,8 @@ +@@ -599,9 +599,8 @@ export function install(rt) { const room = rt.roomOf(roomId) const member = rt.botOf(botId) const sessionId = room?.sessions?.[botId] @@ -1780,7 +1780,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js await postToRoom(room, member, '', { quiet: true, stopped: true }) return true } -@@ -615,37 +1073,42 @@ +@@ -615,37 +615,42 @@ export function install(rt) { const member = rt.botOf(owner?.botId) if (room === undefined || member === undefined || !room.members.includes(member.id)) return const { code, status, message } = failure @@ -1839,7 +1839,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js .catch(error => rt.warn('ds-bot: group report to %s failed: %s', post.name, error)) } -@@ -665,7 +1128,7 @@ +@@ -665,7 +665,7 @@ export function install(rt) { // message from the user, a scheduled task, or a Bot's post after the last model or // tool step starts a relay. const opens = message => message.role === 'user' diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 5177e601..6e0fe5d0 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -752,6 +752,14 @@ export function install(rt) { || (kind === 'bot' && (state.bots?.[me.id]?.ownerId === actor.memberId || me.ownerId === actor.memberId || room.sharedFrom?.ownerId === actor.memberId)) ) if (kind === 'bot' && room.origin !== 'inbound-share' && room.origin !== 'face-to-face' && !me.remote && botsLeft.length < 2 && !ownerLeave) throw invalid('A group chat needs at least two Bots') + // #1167: reach and a room link's pairs only see who is still in the room. Tell the other home + // before leftSeq, or that leave is refused and their copy of the room stays open. + if (me.remote) { + await Promise.resolve(rt.notifyRoomShare?.(room, { op: 'leave', handle: me.handle || me.id })) + .catch(error => rt.warn?.('ds-bot: a seat\'s leave was not sent: %s', error)) + } else if (kind === 'bot' && room.origin === 'inbound-share' && room.sharedFrom?.ticket) { + await sendTicketLeave(room, me).catch(error => rt.warn?.('ds-bot: leaving %s was not sent: %s', room.name, error)) + } const line = append(room, { kind: 'left', who: me.displayName, @@ -767,12 +775,10 @@ export function install(rt) { } syncMembers(room) await rt.save() - if (me.remote) { - void Promise.resolve(rt.notifyRoomShare?.(room, { op: 'leave', handle: me.handle || me.id })) - .then(() => (isTicketSeat(me) ? settleTicket(room, me.ticket, me) : undefined)) - .catch(error => rt.warn?.('ds-bot: a seat\'s leave was not settled: %s', error)) + if (me.remote && isTicketSeat(me)) { + await settleTicket(room, me.ticket, me).catch(error => rt.warn?.('ds-bot: a seat\'s leave was not settled: %s', error)) } else if (kind === 'bot' && room.origin === 'inbound-share' && room.sharedFrom?.ticket) { - void leaveTicketRoom(room, me).catch(error => rt.warn?.('ds-bot: leaving %s was not sent: %s', room.name, error)) + await finishTicketLeave(room).catch(error => rt.warn?.('ds-bot: leaving %s was not settled: %s', room.name, error)) } else if (kind === 'bot' && !me.remote) { reshareRoster(room) } @@ -967,7 +973,9 @@ export function install(rt) { // and is told about the room by that Bot. const isTicketSeat = p => p?.remote === true && p.admittedBy === 'ticket' function seatSender(room, seat) { - const botId = seat.hostBotId && rt.botOf?.(seat.hostBotId) && room.members?.includes(seat.hostBotId) ? seat.hostBotId : undefined + const named = seat.hostBotId && rt.botOf?.(seat.hostBotId) && room.members?.includes(seat.hostBotId) ? seat.hostBotId : undefined + // The same fallback ticketPeers uses: a seat written before hostBotId still leaves from the room lead. + const botId = named ?? (room.admin && rt.botOf?.(room.admin) && room.members?.includes(room.admin) ? room.admin : undefined) if (!botId) return undefined const member = state.bots?.[botId]?.fields?.social?.member return { botId, from: { ...shareInviter(room), inviterHandle: member?.handle, ...(member?.home ? { inviterHome: member.home } : {}) } } @@ -2024,28 +2032,30 @@ export function install(rt) { } // The guest's side of a seat (#884): a Bot of mine that a ticket seated leaves the room it mirrors. - // The inviter's home hears it from that Bot, and when none of mine is left the claim is given back. - async function leaveTicketRoom(room, me) { + // The inviter's home hears it from that Bot. Sent while the Bot is still seated (#1167): after + // leftSeq the pair is gone and the host never hears the leave. The claim is given back only once + // that Bot is no longer current, so the "am I the last?" check stays after leftSeq. + async function sendTicketLeave(room, me) { const inviter = room.sharedFrom?.inviterHandle const scope = room.sharedFrom?.remoteRoomId - if (inviter && scope) { - await sendShareTo(room, { handle: inviter, id: inviter }, { - op: 'leave', - roomId: scope, - name: room.name, - invitedHandle: inviter, - seq: room.seq ?? 0, - ticket: room.sharedFrom.ticket, - }, 'room-share', me.id, scope) - } + if (!inviter || !scope) return + await sendShareTo(room, { handle: inviter, id: inviter }, { + op: 'leave', + roomId: scope, + name: room.name, + invitedHandle: inviter, + seq: room.seq ?? 0, + ticket: room.sharedFrom.ticket, + }, 'room-share', me.id, scope) + } + async function finishTicketLeave(room) { const stays = currentParticipants(room).some(p => p.kind === 'bot' && !p.remote) - if (!stays) { - if (state.roomClaims?.[room.sharedFrom.ticket]) { - delete state.roomClaims[room.sharedFrom.ticket] - await rt.save?.() - } - await endServerTicket(room.sharedFrom.ticket) + if (stays || !room.sharedFrom?.ticket) return + if (state.roomClaims?.[room.sharedFrom.ticket]) { + delete state.roomClaims[room.sharedFrom.ticket] + await rt.save?.() } + await endServerTicket(room.sharedFrom.ticket) } // A seat of this home's own room that the sender is, for a share addressed to that room. @@ -2093,6 +2103,9 @@ export function install(rt) { // `op: 'line'` envelope to the friend who invited this Bot, under *their* room id, one hop on, // carrying the handles it calls there. if (room.origin === 'inbound-share' && !isFaceRoom(room)) { + // #1167: a room the host already took back does not keep sending, even from a Session that is + // still open. sharedFrom.localBotId would otherwise still name a sender after everyone left. + if (room.hidden || !currentParticipants(room).some(p => p.kind === 'bot' && !p.remote)) return const inviter = room.sharedFrom?.inviterHandle if (!inviter || !rt.flybotSend) return // A Bot of mine a ticket seated speaks for itself, under that room's scope (#884). @@ -2305,7 +2318,7 @@ export function install(rt) { }) } - function markInboundLeft(room) { + function markInboundLeft(room, removedSeq) { const at = Date.now() const line = append(room, { kind: 'left', who: room.sharedFrom?.inviterName || 'the room', authorKind: 'bot', text: '' }) for (const p of currentParticipants(room)) { @@ -2313,6 +2326,10 @@ export function install(rt) { p.leftAt = at } room.hidden = true + // #1167: the host's seq at the leave. A join at or before it is a roster refresh already in + // flight, not a re-invite; a later join may open the room again. + const seq = Number(removedSeq) + room.sharedFrom = { ...(room.sharedFrom ?? {}), removedSeq: Number.isFinite(seq) ? seq : (room.seq ?? line.seq) } syncMembers(room) return room } @@ -2397,11 +2414,19 @@ export function install(rt) { } syncMembers(room) } else { - markInboundLeft(room) + markInboundLeft(room, data.seq) } await rt.save?.() return { handled: true, room } } + // #1167: a line, or a join that was already on its way when the seat was removed, must not open + // a room a leave just hid. A newer join is the host adding this Bot again. + if (room?.hidden) { + const removed = Number(room.sharedFrom?.removedSeq) + const seq = Number(data.seq) + const newerJoin = data.op === 'join' && Number.isFinite(seq) && (!Number.isFinite(removed) || seq > removed) + if (!newerJoin) return { handled: true, refused: 'left', room } + } if (!room) { room = await rt.recordInboundSharedRoom({ name: data.name, @@ -2419,6 +2444,7 @@ export function install(rt) { }, actor || (ownerId ? { memberId: ownerId } : undefined)) } else { delete room.hidden + if (room.sharedFrom) delete room.sharedFrom.removedSeq // A ticket seats each Bot of mine on its own (#878): the join names one, and only that one comes // back or is added. A friend's share keeps the room's one Bot. const seatedHere = ticket && data.op === 'join' && localBotId diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index 01b0ce90..7fb25ac6 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -2205,3 +2205,122 @@ test('#904: a seat\'s line is attributed to the seat, not to whatever the envelo const invented = room.log.filter(entry => String(entry.text).includes('line 3')).at(-1) assert.equal(invented.authorId, '@chief.bob.home', 'an invented handle never becomes an author') }) + +const BOB_HOME = { + version: 2, + revision: 1, + mainBotIds: ['chief-b'], + members: { 'owner-b': { id: 'owner-b', createdAt: 1, designatedChiefId: 'chief-b' } }, + bots: { + 'chief-b': { + id: 'chief-b', sessionId: 'chief-b', name: 'Chief', instructions: '', createdAt: 1, ownerId: 'owner-b', + fields: { social: { member: { handle: '@chief.bob.home', home: '@bob.home', ownerName: 'Bob' } } }, + }, + }, + rooms: {}, + exchanges: [], + exchangeTurns: {}, + questions: {}, +} + +const settleShares = async () => { + for (let i = 0; i < 12; i += 1) await new Promise(resolve => setImmediate(resolve)) +} + +// #1167: adding a friend's Bot is allowed by the seat (an open room link, no person-grant). +// Reach only treats current participants as reachable, so a leave sent after leftSeq never +// arrives and the other home keeps the room. +test('#1167: removing a friend\'s Bot drops that home\'s room, and later lines do not return', async (t) => { + const a = host(t, TEAM) + const b = host(t, BOB_HOME) + await a.rt.load() + await b.rt.load() + const invite = await a.rt.createInvitation({ createdByMemberId: 'owner-1' }) + const alice = await a.rt.redeemInvitation(invite.token, { displayName: 'Alice' }) + const actorA = await a.rt.authenticate(alice.sessionToken) + const inviteB = await b.rt.createInvitation({ createdByMemberId: 'owner-b' }) + const bob = await b.rt.redeemInvitation(inviteB.token, { displayName: 'Bob' }) + const actorB = await b.rt.authenticate(bob.sessionToken) + a.rt.state.bots['chief-a'].fields = { social: { member: { handle: '@chief.alice.home', home: '@alice.home', ownerName: 'Alice' } } } + a.rt.state.bots['chief-b'].fields = { social: { member: { handle: '@helper.alice.home', home: '@alice.home', ownerName: 'Alice' } } } + publish(a.rt, [{ handle: '@chief.bob.home', name: 'Chief', home: '@bob.home', homeName: 'Bob', owner: 'Bob', plugin: 'social', remote: true }]) + // No person-grant. An open link is the consent that lets this home's Bots speak in the room. + a.rt.roomInviteServer = { mint: async () => ({ ticket: 'Tkt0001AbCdEfGhIjKlMnO', expiresAt: null }) } + await a.rt.mintRoomInvite('war', actorA) + const traffic = [] + const wire = (from, to, home) => { + from.rt.flybotSend = async (msg) => { + const verdict = from.rt.reach(msg) + traffic.push({ op: parseRoomShare(msg.text)?.op, action: verdict.action, to: msg.to }) + if (verdict.action !== 'allow') return { action: 'deny', reason: verdict.reason } + const localBotId = Object.keys(to.rt.state.bots).find(id => to.rt.state.bots[id]?.fields?.social?.member?.handle === msg.to) + const sender = from.rt.state.bots[msg.from] + await to.rt.applyInboundRoomShare({ + text: msg.text, + fromHandle: sender?.fields?.social?.member?.handle, + fromHome: home, + fromName: sender?.fields?.social?.member?.ownerName || 'Alice', + localBotId, + }) + return { action: 'sent' } + } + } + wire(a, b, '@alice.home') + wire(b, a, '@bob.home') + await a.rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@chief.bob.home' }, actorA) + await settleShares() + const guest = () => Object.values(b.rt.state.rooms).find(room => room.origin === 'inbound-share') + assert.ok(guest(), 'the add projected the room on the friend\'s home') + assert.equal(traffic.some(item => item.op === 'join' && item.action === 'allow'), true, 'the join left while the seat was live') + const hello = await a.rt.postRoomMessage('war', { text: '测1' }, actorA) + await a.rt.fanoutRoomLine(a.rt.state.rooms.war, hello) + await settleShares() + assert.ok(guest().log.some(line => String(line.text).includes('测1')), 'a line while seated arrives') + + await a.rt.removeRoomParticipant('war', { kind: 'bot', id: '@chief.bob.home' }, actorA) + await settleShares() + const leave = traffic.findLast(item => item.op === 'leave') + assert.equal(leave?.action, 'allow', 'the leave is sent while the seat can still be reached') + assert.equal(guest().hidden, true, 'the friend\'s home hid the room') + assert.equal(roomVisibleTo(b.rt.state, actorB, guest().id), false, 'the room drops out of that home\'s list') + + const after = await a.rt.postRoomMessage('war', { text: '踢后测4' }, actorA) + await a.rt.fanoutRoomLine(a.rt.state.rooms.war, after) + await settleShares() + assert.equal(guest().log.some(line => String(line.text).includes('踢后测4')), false, 'a line after the remove does not arrive') + const before = a.rt.state.rooms.war.log.length + await b.rt.fanoutRoomLine(guest(), { who: 'Bob', authorId: 'owner-b', authorKind: 'human', text: 'B踢后测5', seq: (guest().seq ?? 0) + 1 }) + await settleShares() + assert.equal(a.rt.state.rooms.war.log.length, before, 'the removed home cannot send into the room') + assert.equal(traffic.findLast(item => item.op === 'line' && String(item.to).includes('alice'))?.action, undefined) +}) + +test('#1167: a hidden shared room stays hidden for a later line or a stale join, and a newer join reopens it', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const share = (op, seq, extra = {}) => encodeRoomShare({ + op, roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@chief.bob.home', seq, ...extra, + }) + const deliver = text => rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', text, localBotId: 'chief-b', + }, actorB) + const joined = await deliver(share('join', 4)) + const room = joined.room + assert.equal(room.hidden, undefined) + await deliver(share('leave', 8)) + assert.equal(room.hidden, true, 'the leave hid the mirror') + const line = await deliver(share('line', 9, { who: 'Alice', authorKind: 'human', text: '踢后测4' })) + assert.equal(line.refused, 'left', 'a line after the leave is refused') + assert.equal(room.hidden, true, 'the line does not bring the room back') + assert.equal(room.log.some(entry => String(entry.text).includes('踢后测4')), false) + const stale = await deliver(share('join', 8, { roster: [{ handle: '@chief.alice.home', name: 'Chief', ownerName: 'Alice' }] })) + assert.equal(stale.refused, 'left', 'a join from before the remove is not a re-invite') + assert.equal(room.hidden, true) + const again = await deliver(share('join', 12)) + assert.equal(again.refused, undefined, 'a later join is a new invite') + assert.equal(room.hidden, undefined, 'the room opens again') + assert.equal(roomVisibleTo(rt.state, actorB, room.id), true) +}) diff --git a/patches/ds-bot/files/test/room-ticket-seat.test.js b/patches/ds-bot/files/test/room-ticket-seat.test.js index 8e1928e7..366a6ab5 100644 --- a/patches/ds-bot/files/test/room-ticket-seat.test.js +++ b/patches/ds-bot/files/test/room-ticket-seat.test.js @@ -351,8 +351,9 @@ test('#892: withdrawing the link stops new joins and keeps the seat; removing th assert.equal(mirror.hidden, true, 'the guest heard the leave') assert.equal(a.rt.state.rooms.war.localInvites.length, 0, 'a withdrawn link that seats nobody is dropped') assert.equal(mayShareRoom(a.rt.state, a.rt.state.rooms.war, 'writer-a'), false, 'with no link left, Writer is private again') + const beforeSend = log.length await b.rt.fanoutRoomLine(mirror, { who: 'Chief', botId: 'chief-b', authorId: 'chief-b', authorKind: 'bot', text: 'still there?', seq: 100 }) - assert.equal(log.at(-1).delivered, false) + assert.equal(log.length, beforeSend, 'the guest sends nothing once the host has removed them') const before = Object.keys(a.rt.state.rooms).length const stray = await a.rt.applyInboundRoomShare({ text: `FLYBOT-ROOM-SHARE\n${JSON.stringify({ v: 1, op: 'line', roomId: 'war', text: 'hi', seq: 1 })}`, fromHandle: '@chief.bob.home', fromHome: '@bob.home' }) assert.equal(stray.refused, 'not-seated')